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

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.
| Mod | What it does |
|---|---|
slick-bar | Replaces the hint line under the prompt with a one-line bar: model, effort, folder, branch, context gauge, prompt-cache health, rate-limit gauges |
image-peek | Shows a preview of a pasted image above the prompt and under the message that sent it |
tool-cards | Draws each tool call as a boxed card: highlighted command, output preview, timing footer |
cache-warm | Keeps 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-mod | Gives Claude a show_diagram tool: animated diagrams that play a story step by step, with packets, gauges and a live log |
git clone https://github.com/mustafa89/my-claude-code-mods.git ~/.claude/mods
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.
<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.

One line under the prompt, after Claude Code's own permission-mode label:
✻ Opus 5.5 1M · medium. The ✻ turns into ◉ while a turn runs.± when the tree has changes, ↑/↓ for commits ahead of or behind the upstream.↻ 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%.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 #3 · 1246×846).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.

Every tool call becomes a card:
✓ done, ◌ running, ✗ error, ⊘ interrupted. The command's description follows.+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.▾ output · N lines. JSON is indented; errors show in red.src/cart/total.js:10-30 for a Read.◇ 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.
▾ 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.

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.
$.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.Prompt caching prices, as a multiple of the normal input price:
| Request | Price |
|---|---|
| Cache write, 1-hour cache | 2× |
| Cache write, 5-minute cache | 1.25× |
| Cache read | 0.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.
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.
maxPings pings (default 4) per idle stretch. A prompt you type resets the count.| Setting | Values | Default |
|---|---|---|
ttl | 1h, 5m | 1h |
maxPings | a number | 4 |
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.

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.
git config user.email.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 calendar file holds the first message of each session as plain text. It stays on your machine.
~/.calendar/config.json is created on the first run:
| Setting | Default | What it does |
|---|---|---|
dayStartHour | 6 | Work before this hour counts toward the previous day |
gapMinutes | 30 | A gap longer than this starts a new block |
minNoCommitMinutes | 15 | Shorter blocks are not flagged as ending without a commit |
weekStart | mon | mon or sun |
theme | dark | dark or light |
accent, palette | Colours 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.

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:
✓ ok, ✗ error, ! warning.meters) that fill in eighths of a cell, and a moving activity wave (spark).side: "left" or "right" becomes a tall panel beside the tree, with rows a step can highlight.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.
| Setting | Default | What it does |
|---|---|---|
display | inline | inline: 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) |
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.
These come from the mods API, not from the mods:
Ctrl+o: tool rows do not report the expanded view (see Expanding output).ttl setting.mcp__<mod>__<tool>, so diagram-mod's tool is mcp__diagram-mod__show_diagram though no MCP server is involved.hooks/register.tsx 540 lines1import { 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}
540types/index.d.ts 17 lines1/** 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