SLOPSHOPPER

tool-cards

Draws tool calls as boxed cards: header, highlighted command, output preview, timing footer

newrowsguardcommand
v0.1.0MITupdated 2026-10-09mustafa89/my-claude-code-mods/tool-cards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-cards
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ → Edit ✓ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ │ src/auth.ts │ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ⏺ 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 › /cards ⎿ tool-cards: Tool cards show their full output. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
┌─────────────────────────────────────────────────────────────────────────────────────────────────── │ → Edit ✓ │ ───────────────────────────────────────────────────────────────────────────────────────────────── │ src/auth.ts └───────────────────────────────────────────────────────────────────────────────────────────────────
README

my-claude-code-mods

Six mods for Claude Code: a one-line status bar, image previews for pasted screenshots, boxed cards for tool calls, a keepalive for the prompt cache, a weekly calendar of your sessions, and animated diagrams Claude can draw in the transcript.

Mods are Claude Code plugins built from function hooks. They need Claude Code 2.1.287 or newer. Mods are an early-access feature: the hooks API can change between releases.

ModWhat it does
slick-barReplaces the hint line under the prompt with a one-line bar: model, effort, folder, branch, context gauge, prompt-cache health, rate-limit gauges
image-peekShows a preview of a pasted image above the prompt and under the message that sent it
tool-cardsDraws each tool call as a boxed card: highlighted command, output preview, timing footer
cache-warmKeeps the prompt cache of an idle chat warm with a small capped ping, and counts cache hits and misses
week-calendar/week draws a calendar of the week's sessions with commits, hours per project, and a 3-line report
diagram-modGives Claude a show_diagram tool: animated diagrams that play a story step by step, with packets, gauges and a live log

Install

  1. Clone the repo:
   git clone https://github.com/mustafa89/my-claude-code-mods.git ~/.claude/mods
  1. Tell Claude Code to load the mods. Add this to the env block of ~/.claude/settings.json (paths separated by :; ~ is allowed):
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/slick-bar:~/.claude/mods/image-peek:~/.claude/mods/tool-cards:~/.claude/mods/cache-warm:~/.claude/mods/week-calendar:~/.claude/mods/diagram-mod"
     }
   }

Leave out the mods you do not want. slick-bar lists cache-warm as a dependency for its cache segment, so load the two together.

  1. Start a new Claude Code session. Each mod prints <name>: loaded in the transcript.

To try one mod for a single session only:

claude --plugin-dir ~/.claude/mods/slick-bar

If you use a statusLine command, the bar does not replace it. The bar sits on the hint line under the prompt; your status line stays below it. Remove statusLine from your settings if you want only the bar.

slick-bar

slick-bar

One line under the prompt, after Claude Code's own permission-mode label:

  • Model and effort: ✻ Opus 5.5 1M · medium. The ✻ turns into ◉ while a turn runs.
  • Folder and branch: repository name (plus subfolder), branch, ± when the tree has changes, ↑/↓ for commits ahead of or behind the upstream.
  • Context gauge: how full the context window is, green → amber → red.
  • Rate-limit gauges (subscription plans): 5-hour and 7-day usage, the time until each window resets (↻ 3h12m), and a ┃ tick that marks how much of the window has passed. Usage to the right of the tick means you are using it faster than the window allows; the percentage turns amber when the current pace projects past 100%.
  • Prompt cache (with cache-warm): cache ● 97% 52m ✗1 ⟳2. The dot is green while the cache is warm, amber in the last minutes before it expires, and ○ cold after. Then the share of the last request served from cache, the time until the cache expires, misses (✗) and keepalive pings (⟳).

When the terminal is narrow, segments drop in this order: hint text, 7-day gauge, token count, cache, branch, folder. The model and the context gauge always stay.

The bar needs a Nerd Font for the pill shapes and icons.

image-peek

image-peek

  • While you write a prompt, every pasted image shows above the prompt with its size (Image #3 · 1246×846).
  • After you send it, the image shows under your message in the transcript.

image-peek draws real images through the kitty graphics protocol, so it needs a terminal that supports it, such as kitty or Ghostty. Elsewhere it shows the image's label instead.

It also needs macOS: it uses sips to make a smaller copy of each image.

Terminal multiplexers: inside a multiplexer that passes kitty graphics through but identifies itself differently (for example herdr, which reports libghostty), Claude Code turns graphics off. To turn them on, add this to the same env block:

"CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"

Only do this if your terminal really supports kitty graphics. Otherwise images draw as empty boxes.

tool-cards

tool-cards

Every tool call becomes a card:

  • Header: tool name and status: ✓ done, ◌ running, ✗ error, ⊘ interrupted. The command's description follows.
  • Bash: the command with syntax colours, then the first 5 lines of output (errors in red).
  • Edit and Write: a split diff, old on the left and new on the right, with line numbers, red and green rows, and the changed words highlighted. A +N -M header with a bar shows the size of the change. Narrow terminals stack old over new. A new file from Write keeps Claude Code's own view.
  • MCP tools: the arguments, then the output folded behind ▾ output · N lines. JSON is indented; errors show in red.
  • Other tools: one line saying what the call acted on, for example src/cart/total.js:10-30 for a Read.
  • Footer: how long the call took, how many words it printed, and the Bash timeout if one was set.
  • Diagrams from diagram-mod: a rounded card headed ◇ diagram, as wide as the diagram, with the animation inside and ▴ collapse / ▾ expand.

Claude Code normally folds runs of reads and searches into one line (Searched for 2 patterns). tool-cards unfolds them, so each call gets its own card.

Expanding output

  • Click ▾ expand on a card to see all of its output or diff (up to 400 lines). Clicks reach the card in fullscreen mode.
  • /cards full expands every card; /cards compact goes back to the preview (5 output lines, 16 diff rows, MCP output folded); /cards switches between the two.

Ctrl+o does not expand a card. Claude Code does not tell mods when the Ctrl+o view is open.

cache-warm

cache-warm

Claude Code caches the conversation on the API side. A request that reads the cache pays a small part of the input price; a request after the cache expired writes the whole conversation again. cache-warm sends one small request just before the cache expires, so an idle chat stays warm.

How it works

  • Timer: a 15-second timer runs inside the Claude Code session. There is no cron job; the timer stops when the session closes or the computer sleeps.
  • Ping: when the chat is idle and the cache is 2 minutes from expiry, the mod calls $.model.fork. The fork sends the session's last request again with a one-line prompt. The API reads the conversation from the cache, and that read starts the cache lifetime again. The fork runs no tools and does not show in the transcript.
  • Hits and misses: after each turn, the mod reads the token counts of the turn's first request. If less than half of the input came from the cache, the turn counts as a miss.

Why it is worth it

Prompt caching prices, as a multiple of the normal input price:

RequestPrice
Cache write, 1-hour cache2×
Cache write, 5-minute cache1.25×
Cache read0.1× (Opus 5.5: 0.05×, Fable 5.1: 0.025×)

Example: a 400k-token chat on Opus 5.5 with the 1-hour cache.

  • The cache expires: your next prompt writes 400k tokens at 2×, the cost of 800k input tokens.
  • The cache is warm: your next prompt reads 400k tokens at 0.05×, the cost of 20k input tokens.
  • One ping: the same read, 20k.

A ping costs 1/40 of a cold restart. Four pings cost 80k, one tenth of a restart, even if you never come back to the chat. The keepalive pays for itself when there is a better than 1-in-40 chance that you come back within the next hour. At the general read price of 0.1×, the break-even is 20 pings.

Limits on the pings

  • Cap: at most maxPings pings (default 4) per idle stretch. A prompt you type resets the count.
  • Rate limit: no pings while the 5-hour usage window is 80% full or more.
  • Expired cache: no ping after the cache expired, for example after the computer slept. A ping then would pay a full cache write.
  • Missed ping: when a ping does not read from the cache, the mod stops pinging until your next turn.
  • Failed ping: an API error does not count as a refresh. The next tick tries again.

Settings and command

SettingValuesDefault
ttl1h, 5m1h
maxPingsa number4

Change them in /config. Claude Code sessions use the 1-hour cache on most plans. Use 5m for the API default, or when your account runs in usage overage.

/cache-warm status shows the cache state, /cache-warm off stops the pings, /cache-warm on starts them again.

week-calendar

week-calendar

The screenshot shows a real week with every title, project and commit replaced.

/week builds a calendar of this week's Claude Code and Codex sessions, opens it in your browser, and prints a 3-line report:

Shipped: rate limiting, CI runner migration, deploy runbook
Most time: api-service, 22h 27m
Next: Prototype caching layer, Upgrade database driver

/week last shows last week; /week -2 goes two weeks back.

What the calendar shows

  • Sessions as blocks, one colour per project. A session splits into blocks at gaps longer than 30 minutes.
  • Commits you made during each block, matched by your git config user.email.
  • Sessions that ended without a commit: a dashed outline and an amber dot. The sidebar lists them, longest first.
  • Active time: hours per project and per day. Sessions that run in parallel count once.
  • Details: click a block to see its time, model, commits, and the first message you typed.

How it works

  • bin/build.mjs reads ~/.claude/projects/**/*.jsonl and ~/.codex/sessions/**/*.jsonl, runs git log in each project, and writes one HTML file to ~/.calendar/week-<monday>.html. Node only, no network.
  • The hook sends the week's commit subjects to Sonnet once to write the "Shipped" line.
  • The HTML file holds all its data inline and loads nothing from the network.

The calendar file holds the first message of each session as plain text. It stays on your machine.

Settings

~/.calendar/config.json is created on the first run:

SettingDefaultWhat it does
dayStartHour6Work before this hour counts toward the previous day
gapMinutes30A gap longer than this starts a new block
minNoCommitMinutes15Shorter blocks are not flagged as ending without a commit
weekStartmonmon or sun
themedarkdark or light
accent, paletteColours for today, the busiest day, and the projects

It needs Node and git. It opens the file with open on macOS or xdg-open on Linux.

diagram-mod

diagram-mod

Ask Claude for a diagram or a visual explanation and it calls the mod's show_diagram tool. The diagram draws in the tool's row in the transcript:

  • Boxes: a label, detail lines and a status, with dashed borders in the colour of their role. A status has a tone: a spinner while running, ✓ ok, ✗ error, ! warning.
  • Edges: dashed arrows with optional labels. The layout runs top to bottom and is computed from the edges; Claude never gives coordinates. Edges that point back up, or skip a row, run along the right margin.
  • Packets: braille "comets" with a fading tail, moving a quarter cell at a time.
  • Gauges: bars (meters) that fill in eighths of a cell, and a moving activity wave (spark).
  • Side panels: a node with side: "left" or "right" becomes a tall panel beside the tree, with rows a step can highlight.
  • Frame: a legend under the title, a caption under the diagram, a log table (time, actor, message, status) and a footer of counters.

Stories

Give the spec steps and the diagram plays them in a loop. Each step names the edges carrying traffic (only those light up), changes node statuses, meters and panel highlights, adds log rows, and moves the counters. Meters ease to their new values and counters count up across the step. Without steps, packets loop on the edges you name.

examples/agent-tree.json is the story in the GIF; examples/dispatcher.json is a simple diagram without steps. The bundled skill tells Claude when to use the tool and how to keep a diagram readable: one idea, about 12 nodes at most, labels of 1-3 words, 3-6 steps.

How it animates

  • About 60 frames a second, repainting one cell grid in place; frames that would not change are skipped.
  • Only the newest diagram moves; older ones keep a still frame.
  • The animation waits while Claude is still writing its reply, so the streaming text stays fast, and pauses while the row is scrolled out of view or folded.
  • A terminal narrower than the diagram gets a still text version instead.

Settings

SettingDefaultWhat it does
displayinlineinline: the diagram animates in the transcript row. pane: it opens in a side pane, closed with q or Esc (Claude Code only seats a pane it did not ask for from 144 columns)

Development

Each mod is a folder:

<mod>/
  .claude-plugin/plugin.json   name, version, description, types
  hooks/hooks.json             { "modules": ["./register.tsx"] }
  hooks/register.tsx           the hooks
  types/index.d.ts             the state the mod keeps
  tests/*.test.tsx             tests

Check and test a mod:

claude plugin validate ./slick-bar
claude plugin test ./slick-bar

Claude Code writes the API types into <mod>/.claude-plugin/types/ the first time it loads a mod; those files are not committed. A session started with --plugin-dir reloads a mod when you save its files.

Limits

These come from the mods API, not from the mods:

  • Permission mode: mods cannot read it, so the bar sits after Claude Code's own mode label.
  • Space above the prompt: the empty row between the transcript and the prompt belongs to Claude Code.
  • Ctrl+o: tool rows do not report the expanded view (see Expanding output).
  • Cache lifetime: mods cannot read which cache lifetime the session uses, so cache-warm takes it from the ttl setting.
  • Tool names: Claude Code lists a tool a mod registers as mcp__<mod>__<tool>, so diagram-mod's tool is mcp__diagram-mod__show_diagram though no MCP server is involved.

License

MIT

Source 2 files
hooks/register.tsx 540 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, Hook, Register, RenderElement } from 'claude-code'
3
4const times = atom({ plugin: 'tool-cards', key: 'times' } as const, {})
5const expanded = atom({ plugin: 'tool-cards', key: 'expanded' } as const, {})
6const showAll = atom({ plugin: 'tool-cards', key: 'showAll' } as const, false)
7
8const C = {
9  frame: '#3B414D',
10  name: '#E8A15A',
11  ok: '#7EE787',
12  fail: '#FF7B72',
13  busy: '#F2CC60',
14  cmd: '#E8A15A',
15  arg: '#9FCB78',
16  text: '#D7DCE4',
17  muted: '#7F8796',
18  diagram: '#7DCFFF',
19  diagramFrame: '#2E5A6B',
20}
21
22const PREVIEW_LINES = 5
23// An expanded card stops here; beyond it the output is better read in a file.
24const FULL_LINES = 400
25const COMMAND_LINES = 3
26const KEEP_TIMES = 300
27const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g
28
29type Kind = 'cmd' | 'flag' | 'str' | 'op' | 'arg' | 'ws'
30
31// Splits a shell command into coloured tokens: the command word of each pipeline stage,
32// its flags, quoted strings, operators and plain arguments.
33function shellTokens(command: string) {
34  const re = /(\s+)|('[^']*'|"(?:\\.|[^"\\])*")|(\|\||&&|[|;&<>]+)|([^\s'"|;&<>]+)/g
35  const out: { text: string; kind: Kind }[] = []
36  let expectCommand = true
37  for (const m of command.matchAll(re)) {
38    if (m[1] !== undefined) out.push({ text: m[1], kind: 'ws' })
39    else if (m[2] !== undefined) out.push({ text: m[2], kind: 'str' })
40    else if (m[3] !== undefined) {
41      out.push({ text: m[3], kind: 'op' })
42      expectCommand = true
43    } else if (m[4] !== undefined) {
44      const word = m[4]
45      if (expectCommand && !/^\w+=/.test(word)) {
46        out.push({ text: word, kind: 'cmd' })
47        expectCommand = false
48      } else out.push({ text: word, kind: word.startsWith('-') ? 'flag' : 'arg' })
49    }
50  }
51  return out
52}
53
54const TOKEN_COLOR: Record<Kind, string> = {
55  cmd: C.cmd,
56  flag: C.cmd,
57  op: C.cmd,
58  str: C.arg,
59  arg: C.arg,
60  ws: C.text,
61}
62
63function duration(ms: number) {
64  if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
65  return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
66}
67
68function words(text: string) {
69  const n = text.match(/\S+/g)?.length ?? 0
70  return n >= 1000 ? `~${(n / 1000).toFixed(1)}k words` : `~${n} words`
71}
72
73function lines(text: string) {
74  const all = text.replace(ANSI, '').replace(/\s+$/, '').split('\n')
75  return all.length === 1 && all[0] === '' ? [] : all
76}
77
78const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
79
80// One line saying what a non-Bash call acted on.
81function summary(tool: string, input: Record<string, unknown>, cwd: string) {
82  const rel = (p: string) => (p.startsWith(`${cwd}/`) ? p.slice(cwd.length + 1) : p)
83  const path = str(input.file_path) ?? str(input.notebook_path)
84  switch (tool) {
85    case 'Read': {
86      const offset = typeof input.offset === 'number' ? input.offset : undefined
87      const limit = typeof input.limit === 'number' ? input.limit : undefined
88      const range = offset !== undefined ? `:${offset}${limit !== undefined ? `-${offset + limit}` : ''}` : ''
89      return path !== undefined ? `${rel(path)}${range}` : ''
90    }
91    case 'Grep':
92      return `${str(input.pattern) ?? ''}${str(input.path) ? `  in ${rel(str(input.path)!)}` : ''}`
93    case 'Glob':
94      return str(input.pattern) ?? ''
95    case 'WebFetch':
96      return str(input.url) ?? ''
97    case 'WebSearch':
98      return str(input.query) ?? ''
99    case 'Agent':
100      return `${str(input.subagent_type) ?? 'agent'} · ${str(input.description) ?? ''}`
101    default:
102      if (path !== undefined) return rel(path)
103      return Object.entries(input)
104        .filter(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
105        .map(([k, v]) => `${k}=${String(v).replace(/\s+/g, ' ')}`)
106        .join('  ')
107  }
108}
109
110type Api = Parameters<Hook<'ui.render'>>[0]
111type UI = Elements['terminal']
112type Line = { text: string; isErr: boolean }
113
114function toggle($: Api, ui: UI, id: string, isFull: boolean, label: string) {
115  const { Button } = ui
116  return (
117    <Button
118      key={`toggle-${id}`}
119      plain
120      dimColor
121      label={label}
122      onPress={() => update($, expanded, m => ({ ...m, [id]: !isFull }))}
123    />
124  )
125}
126
127// Output lines with a preview cut and the expand button; a preview of 0 starts folded.
128function outputBlock($: Api, ui: UI, id: string, shown: Line[], isFull: boolean, preview: number) {
129  const { Box, Text } = ui
130  const limit = isFull ? FULL_LINES : preview
131  const hidden = shown.length - Math.min(shown.length, limit)
132  const label = isFull ? '▴ collapse' : limit === 0 ? `▾ output · ${shown.length} lines` : '▾ expand'
133  return (
134    <Box key="out" flexDirection="column">
135      {shown.length === 0 ? <Text color={C.muted}>(no output)</Text> : null}
136      {shown.slice(0, limit).map((l, i) => (
137        <Text key={`o${i}`} color={l.isErr ? C.fail : C.text} wrap={isFull ? 'wrap' : 'truncate-end'}>
138          {l.text === '' ? ' ' : l.text}
139        </Text>
140      ))}
141      {shown.length > preview ? (
142        <Box key="more" gap={2}>
143          {limit > 0 && hidden > 0 ? <Text color={C.muted}>{`… ${hidden} more lines`}</Text> : null}
144          {toggle($, ui, id, isFull, label)}
145        </Box>
146      ) : null}
147    </Box>
148  )
149}
150
151// The text an MCP result carries: its text blocks, JSON pretty-printed.
152function mcpText(out: unknown): string {
153  if (typeof out === 'string') {
154    const t = out.trim()
155    if (/^[[{]/.test(t)) {
156      try {
157        return JSON.stringify(JSON.parse(t), null, 2)
158      } catch {}
159    }
160    return out
161  }
162  if (Array.isArray(out)) {
163    return out
164      .map(b => {
165        const block = (b ?? {}) as { type?: unknown; text?: unknown }
166        return block.type === 'text' ? mcpText(String(block.text ?? '')) : `[${String(block.type ?? 'block')}]`
167      })
168      .join('\n')
169  }
170  const content = out && typeof out === 'object' ? (out as { content?: unknown }).content : undefined
171  if (Array.isArray(content)) return mcpText(content)
172  return out === undefined ? '' : JSON.stringify(out, null, 2)
173}
174
175type Hunk = { oldStart: number; newStart: number; lines: string[] }
176type Span = { text: string; hot: boolean }
177type Side = { n: number; text: string; kind: 'del' | 'add' | 'ctx'; spans?: Span[] }
178type Gap = { gap: true }
179type Row = { left?: Side; right?: Side } | Gap
180
181const DIFF_PREVIEW = 16
182// Below this many columns per side, old stacks over new.
183const SPLIT_MIN = 40
184const BAR = 20
185const DIFF = {
186  del: { num: C.fail, bg: '#3A2228', hot: '#7A2F3A' },
187  add: { num: '#A6D86E', bg: '#2C3A1F', hot: '#4E6E26' },
188  ctx: { num: C.muted, bg: undefined, hot: undefined },
189}
190// Word highlights skip long lines (the LCS is quadratic) and pairs that share too little to read as an edit.
191const WORD_TOKENS = 300
192const WORD_SHARED = 0.3
193
194// Marks the words that differ between a removed line and the line that replaced it.
195function wordSpans(a: string, b: string): [Span[], Span[]] | undefined {
196  const x = a.match(/\w+|\s+|[^\w\s]/g) ?? []
197  const y = b.match(/\w+|\s+|[^\w\s]/g) ?? []
198  if (x.length === 0 || y.length === 0 || x.length > WORD_TOKENS || y.length > WORD_TOKENS) return undefined
199  const dp = Array.from({ length: x.length + 1 }, () => new Array<number>(y.length + 1).fill(0))
200  for (let i = x.length - 1; i >= 0; i--)
201    for (let j = y.length - 1; j >= 0; j--) dp[i]![j] = x[i] === y[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
202  const keepX = new Array<boolean>(x.length).fill(false)
203  const keepY = new Array<boolean>(y.length).fill(false)
204  for (let i = 0, j = 0; i < x.length && j < y.length; ) {
205    if (x[i] === y[j]) {
206      keepX[i++] = true
207      keepY[j++] = true
208    } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) i++
209    else j++
210  }
211  const shared = x.filter((t, i) => keepX[i] && /\S/.test(t)).length
212  const total = Math.max(x.filter(t => /\S/.test(t)).length, y.filter(t => /\S/.test(t)).length)
213  if (total === 0 || shared / total < WORD_SHARED) return undefined
214  const spans = (tokens: string[], keep: boolean[]) =>
215    tokens.reduce<Span[]>((out, text, i) => {
216      // Whitespace between two changed words joins them into one highlight.
217      const hot = !keep[i] || (/^\s+$/.test(text) && !keep[i - 1] && !keep[i + 1] && i > 0 && i < tokens.length - 1)
218      const last = out[out.length - 1]
219      if (last && last.hot === hot) last.text += text
220      else out.push({ text, hot })
221      return out
222    }, [])
223  return [spans(x, keepX), spans(y, keepY)]
224}
225
226function patchOf(output: unknown) {
227  const p = output && typeof output === 'object' ? (output as { structuredPatch?: unknown }).structuredPatch : undefined
228  return Array.isArray(p) && p.length > 0 ? (p as Hunk[]) : undefined
229}
230
231// Pairs a patch side by side: context on both sides, each run of removals beside the additions that replaced it.
232function splitRows(patch: Hunk[]) {
233  const rows: Row[] = []
234  let adds = 0
235  let dels = 0
236  patch.forEach((h, i) => {
237    if (i > 0) rows.push({ gap: true })
238    let o = h.oldStart
239    let n = h.newStart
240    let left: Side[] = []
241    let right: Side[] = []
242    const flush = () => {
243      for (let k = 0; k < Math.max(left.length, right.length); k++) {
244        const l = left[k]
245        const r = right[k]
246        const spans = l && r ? wordSpans(l.text, r.text) : undefined
247        rows.push(spans ? { left: { ...l!, spans: spans[0] }, right: { ...r!, spans: spans[1] } } : { left: l, right: r })
248      }
249      left = []
250      right = []
251    }
252    for (const line of h.lines) {
253      const text = line.slice(1).replace(/\t/g, '  ')
254      if (line[0] === '-') {
255        left.push({ n: o++, text, kind: 'del' })
256        dels++
257      } else if (line[0] === '+') {
258        right.push({ n: n++, text, kind: 'add' })
259        adds++
260      } else if (line[0] !== '\\') {
261        flush()
262        rows.push({ left: { n: o++, text, kind: 'ctx' }, right: { n: n++, text, kind: 'ctx' } })
263      }
264    }
265    flush()
266  })
267  return { rows, adds, dels }
268}
269
270// The same rows one above the other: removals, then their additions, context once.
271function stackRows(rows: Row[]) {
272  const out: (Side | Gap)[] = []
273  let pending: Side[] = []
274  const flush = () => {
275    out.push(...pending)
276    pending = []
277  }
278  for (const r of rows) {
279    if ('gap' in r) {
280      flush()
281      out.push(r)
282    } else if (r.left?.kind === 'ctx') {
283      flush()
284      out.push(r.left)
285    } else {
286      if (r.left) out.push(r.left)
287      if (r.right) pending.push(r.right)
288    }
289  }
290  flush()
291  return out
292}
293
294function cell(ui: UI, side: Side | undefined, w: number, key: string) {
295  const { Box, Text } = ui
296  if (!side) return <Box key={key} width={w} flexShrink={0} />
297  const tone = DIFF[side.kind]
298  return (
299    <Box key={key} width={w} flexShrink={0}>
300      <Box width={6} flexShrink={0}>
301        <Text color={tone.num}>{`${side.kind === 'ctx' ? ' ' : '▌'}${String(side.n).padStart(4)}`}</Text>
302      </Box>
303      <Box flexGrow={1} {...(tone.bg ? { backgroundColor: tone.bg } : {})}>
304        <Text color={C.text} wrap="wrap">
305          {side.text === '' ? ' ' : side.spans ? side.spans.map((sp, i) => (
306            <Text key={`w${i}`} {...(sp.hot && tone.hot ? { backgroundColor: tone.hot } : {})}>
307              {sp.text}
308            </Text>
309          )) : side.text}
310        </Text>
311      </Box>
312    </Box>
313  )
314}
315
316function diffBlock($: Api, ui: UI, id: string, patch: Hunk[], inner: number, isFull: boolean) {
317  const { Box, Text } = ui
318  const { rows, adds, dels } = splitRows(patch)
319  const half = Math.floor((inner - 1) / 2)
320  const isSplit = half >= SPLIT_MIN
321  const all: (Row | Side)[] = isSplit ? rows : stackRows(rows)
322  const limit = isFull ? FULL_LINES : DIFF_PREVIEW
323  const green = adds + dels > 0 ? Math.round((BAR * adds) / (adds + dels)) : 0
324  return [
325    <Box key="diffhead" gap={1}>
326      <Text color={C.muted}>↳ diff</Text>
327      <Text color={DIFF.add.num}>{`+${adds}`}</Text>
328      <Text color={DIFF.del.num}>{`-${dels}`}</Text>
329      <Text color={C.muted}>{isSplit ? 'split' : 'stacked'}</Text>
330      <Text>
331        <Text color={C.muted}>[</Text>
332        <Text color={DIFF.add.num}>{'━'.repeat(green)}</Text>
333        <Text color={DIFF.del.num}>{'━'.repeat(BAR - green)}</Text>
334        <Text color={C.muted}>]</Text>
335      </Text>
336    </Box>,
337    <Box key="diff" flexDirection="column">
338      {isSplit ? (
339        <Box key="cols" gap={1}>
340          <Box width={half} flexShrink={0}>
341            <Text color={C.muted}>{'  old'}</Text>
342          </Box>
343          <Text color={C.muted}>{'  new'}</Text>
344        </Box>
345      ) : null}
346      {all.slice(0, limit).map((r, i) =>
347        'gap' in r ? (
348          <Text key={`r${i}`} color={C.muted}>
349            {'    ⋯'}
350          </Text>
351        ) : 'kind' in r ? (
352          cell(ui, r, inner, `r${i}`)
353        ) : (
354          <Box key={`r${i}`} gap={1}>
355            {cell(ui, r.left, half, 'l')}
356            {cell(ui, r.right, half, 'r')}
357          </Box>
358        ),
359      )}
360      {all.length > DIFF_PREVIEW ? (
361        <Box key="more" gap={2}>
362          {all.length > limit ? <Text color={C.muted}>{`… ${all.length - limit} more rows`}</Text> : null}
363          {toggle($, ui, id, isFull, isFull ? '▴ collapse' : '▾ expand')}
364        </Box>
365      ) : null}
366    </Box>,
367  ]
368}
369
370const isMcp = (tool: string) => tool.startsWith('mcp__')
371// Rows another mod draws (diagram-mod): the card frames and folds what the chain below draws.
372const HANDS_OFF = new Set(['mcp__diagram-mod__show_diagram'])
373
374// Calls whose card draws the result, so the engine's own result block goes.
375function ownsResult(tool: string, output: unknown, isErrored: boolean) {
376  if (tool === 'Bash' || isMcp(tool)) return true
377  return (tool === 'Edit' || tool === 'Write') && !isErrored && patchOf(output) !== undefined
378}
379
380export const register: Register = on => {
381  on('session.start', async ($, e, next) => {
382    await $.command.register({
383      name: 'cards',
384      description: 'Tool cards: show full output on every card (/cards full) or the 5-line preview (/cards compact)',
385    })
386    return next(e)
387  })
388
389  on('command.run', { command: 'cards' }, async ($, e) => {
390    const arg = e.args.trim()
391    const next = arg === 'full' ? true : arg === 'compact' ? false : !(await read($, showAll))
392    await update($, showAll, () => next)
393    await update($, expanded, () => ({}))
394    return { text: next ? 'Tool cards show their full output.' : 'Tool cards show a 5-line preview.' }
395  })
396
397  on('tool.call', async ($, e, next) => {
398    const start = await $.clock.now()
399    const result = await next(e)
400    const ms = (await $.clock.now()) - start
401    await update($, times, map => {
402      const keys = Object.keys(map)
403      const kept = keys.length >= KEEP_TIMES ? Object.fromEntries(keys.slice(-KEEP_TIMES + 1).map(k => [k, map[k]!])) : map
404      return { ...kept, [e.tool_use_id]: ms }
405    })
406    return result
407  })
408
409  // Folded runs of reads and searches unfold, so every call gets its own card.
410  on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
411    e.surface === 'terminal' && !e.props.isExpanded ? next({ ...e, props: { ...e.props, isExpanded: true } }) : next(e),
412  )
413
414  // Bash, MCP and diff cards carry their own output, so the separate result block goes.
415  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
416    if (e.surface !== 'terminal' || HANDS_OFF.has(e.props.tool) || !ownsResult(e.props.tool, e.props.output, e.props.isErrored)) return next(e)
417    const { Box } = $.ui.resolve(e)
418    return <Box />
419  })
420
421  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
422    if (e.surface !== 'terminal') return next(e)
423    const ui = $.ui.resolve(e)
424    const { Box, Text } = ui
425    const p = e.props
426    const input = (p.input ?? {}) as Record<string, unknown>
427    const isBash = p.tool === 'Bash'
428    const width = Math.max(40, (e.viewport?.columns ?? 100) - 2)
429    const inner = width - 6
430    // A diagram card hugs its diagram, so a terminal-wide rule would stretch it: a blank line instead.
431    const rule = HANDS_OFF.has(p.tool) ? <Text> </Text> : <Text color={C.frame}>{'─'.repeat(inner)}</Text>
432    const [timeMap, open, all] = await Promise.all([read($, times), read($, expanded), read($, showAll)])
433    const ms = timeMap[p.tool_use_id]
434    // A card's own button wins over the /cards mode.
435    const isFull = open[p.tool_use_id] ?? all
436
437    const status = p.isRunning
438      ? { mark: '◌', color: C.busy }
439      : p.isInterrupted
440        ? { mark: '⊘', color: C.muted }
441        : p.isErrored
442          ? { mark: '✗', color: C.fail }
443          : { mark: '✓', color: C.ok }
444    const isDiagram = HANDS_OFF.has(p.tool)
445    // A diagram draws its own title and subtitle, so its card header names the kind only.
446    const note = isDiagram ? undefined : str(input.description)
447
448    const header = (
449      <Box key="head" gap={1}>
450        <Text color={isDiagram ? C.diagram : C.name} bold>{isDiagram ? '◇ diagram' : `→ ${p.tool}`}</Text>
451        <Text color={status.color} bold>{status.mark}</Text>
452        {note !== undefined ? <Text color={C.frame}>│</Text> : null}
453        {note !== undefined ? <Text color={C.muted} wrap="truncate-end">{note}</Text> : null}
454      </Box>
455    )
456
457    const body: RenderElement[] = []
458    const footer: string[] = []
459    if (ms !== undefined) footer.push(`◷ ${duration(ms)}`)
460
461    if (isBash) {
462      const command = str(input.command) ?? ''
463      const cmdLines = command.split('\n')
464      body.push(
465        <Box key="cmd" flexDirection="column">
466          {cmdLines.slice(0, COMMAND_LINES).map((line, i) => (
467            <Text key={`c${i}`} wrap="truncate-end">
468              <Text color={C.muted}>{i === 0 ? '$ ' : '  '}</Text>
469              {shellTokens(line).map((t, j) => (
470                <Text key={`t${j}`} color={TOKEN_COLOR[t.kind]}>{t.text}</Text>
471              ))}
472            </Text>
473          ))}
474          {cmdLines.length > COMMAND_LINES ? <Text color={C.muted}>{`  … ${cmdLines.length - COMMAND_LINES} more lines`}</Text> : null}
475        </Box>,
476      )
477
478      if (!p.isRunning) {
479        const out = p.output as { stdout?: string; stderr?: string } | string | undefined
480        const stdout = typeof out === 'string' ? out : (out?.stdout ?? '')
481        const stderr = typeof out === 'string' ? '' : (out?.stderr ?? '')
482        const shown = [
483          ...lines(stdout).map(text => ({ text, isErr: p.isErrored && typeof out === 'string' })),
484          ...lines(stderr).map(text => ({ text, isErr: true })),
485        ]
486        body.push(rule)
487        body.push(outputBlock($, ui, p.tool_use_id, shown, isFull, PREVIEW_LINES))
488        const total = `${stdout}\n${stderr}`.trim()
489        if (total) footer.push(`✎ ${words(total)}`)
490      }
491      if (typeof input.timeout === 'number') footer.push(`■ timeout ${duration(input.timeout).replace('.00', '')}`)
492    } else {
493      const line = isDiagram ? undefined : summary(p.tool, input, await $.session.cwd())
494      if (line) {
495        body.push(
496          <Text key="args" color={C.arg} wrap="truncate-end">
497            {line}
498          </Text>,
499        )
500      }
501      const patch = (p.tool === 'Edit' || p.tool === 'Write') && !p.isErrored ? patchOf(p.output) : undefined
502      if (patch) {
503        body.push(rule)
504        body.push(...diffBlock($, ui, p.tool_use_id, patch, inner, isFull))
505      } else if (HANDS_OFF.has(p.tool) && !p.isRunning) {
506        // Open unless folded by hand; folded, the chain below is not drawn at all.
507        const isOpen = open[p.tool_use_id] ?? true
508        body.push(rule)
509        if (isOpen) body.push(<Box key="drawn" flexDirection="column">{await next(e)}</Box>)
510        body.push(toggle($, ui, p.tool_use_id, isOpen, isOpen ? '▴ collapse' : '▾ expand'))
511        const count = (v: unknown) => (Array.isArray(v) ? v.length : 0)
512        footer.push(`${count(input.nodes)} nodes · ${count(input.edges)} edges`)
513      } else if (isMcp(p.tool) && !p.isRunning) {
514        const text = mcpText(p.output)
515        const isErr = p.isErrored || (p.output as { isError?: unknown } | undefined)?.isError === true
516        body.push(rule)
517        body.push(outputBlock($, ui, p.tool_use_id, lines(text).map(t => ({ text: t, isErr })), isFull, 0))
518        if (text.trim()) footer.push(`✎ ${words(text)}`)
519      }
520    }
521
522    return (
523      <Box
524        flexDirection="column"
525        borderStyle={isDiagram ? 'round' : 'single'}
526        borderColor={isDiagram ? C.diagramFrame : C.frame}
527        paddingX={2}
528        {...(isDiagram ? { alignSelf: 'flex-start' as const } : { width })}
529        marginTop={1}
530      >
531        {header}
532        {body.length > 0 ? rule : null}
533        {body}
534        {footer.length > 0 ? rule : null}
535        {footer.length > 0 ? <Text color={C.muted}>{footer.join(' · ')}</Text> : null}
536      </Box>
537    )
538  })
539}
540
types/index.d.ts 17 lines
1/** How long each tool call took, in ms, by tool_use_id (most recent calls only). */
2export type ToolTimes = Record<string, number>
3
4/** Cards the person expanded, by tool_use_id. */
5export type ToolExpanded = Record<string, boolean>
6
7declare module 'claude-code' {
8  interface PluginState {
9    'tool-cards': {
10      times: ToolTimes
11      expanded: ToolExpanded
12      /** `/cards full`: every card shows its whole output. */
13      showAll: boolean
14    }
15  }
16}
17