While a turn runs, shows what Claude is doing above the prompt: current action, tool count, elapsed time.

<img src="https://cdn.simpleicons.org/claude/D97757" alt="Claude" width="96">
<h3 align="center">claude-mods</h3>
Claude Code mods that make the terminal easier to read: polished replies, a live working band, and prompts you can find again. <a href="https://claude.com/resources/articles/claude-code-mods"><strong>What are mods?</strong></a>
##
<!-- Maintenance status --> <img src="https://img.shields.io/badge/maintenance-actively--developed-brightgreen.svg?&label=Maintenance"> <!-- License --> <img src="https://img.shields.io/badge/License-MIT-brightgreen.svg?&logo=open-source-initiative&logoColor=white" alt="License: MIT"/> <!-- Claude Code version --> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-D97757?&logo=claude&logoColor=white" alt="Claude Code 2.1.287+"/> <!-- Last Commit --> <img src="https://img.shields.io/github/last-commit/healkeiser/claude-mods?logo=github&label=Last%20Commit" alt="Last Commit"/> <!-- Commit Activity --> <a href="https://github.com/healkeiser/claude-mods/pulse" alt="Activity"> <img src="https://img.shields.io/github/commit-activity/m/healkeiser/claude-mods?&logo=github&label=Commit%20Activity"/></a> <!-- GitHub stars --> <img src="https://img.shields.io/github/stars/healkeiser/claude-mods" alt="GitHub Stars"/>
<!-- TABLE OF CONTENTS -->
<!-- ABOUT -->
Three mods for Claude Code: small TypeScript modules that redraw parts of its terminal interface. Each one works on its own, so install only the ones you want.
| Mod | What it changes |
|---|---|
| pretty-markdown | Claude's replies: bordered code blocks with a copy button, inline-code chips, coloured diffs, rounded tables, accented headings, callouts, and a highlighted Next: line |
| working-band | The line above the prompt while Claude works, the folded tool rows, and the footer that closes each turn |
| prompt-cards | Your own prompts: a blue card per prompt, a grey card while one is queued, and a /prompts pane to jump back to any of them |
A matching status line script comes alongside. It is not a mod, so it installs on its own.
<!-- INSTALLATION -->
Add this repository as a plugin marketplace, then install the mods you want at user scope:
claude plugin marketplace add healkeiser/claude-mods
claude plugin install pretty-markdown@claude-mods --scope user
claude plugin install working-band@claude-mods --scope user
claude plugin install prompt-cards@claude-mods --scope user
Run /reload-plugins in a session that is already open; new sessions load them by themselves.
[!IMPORTANT] Mods need Claude Code 2.1.287 or later.
<!-- USAGE -->
Redraws each block of Claude's reply that has something to gain; plain prose keeps Claude Code's own look.
⧉ copy button. Past 10 lines they get line numbers; past 20 they fold to 12 with a ▾ show all N lines button. `diff ) draw in red and green, a hunk header added when the fence has none.## lines and bold-only lines such as What changed:) draw in the accent colour with a rule under them.> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) get their own colour and icon.Next: draws in its own box, so the action a reply ends on is easy to spot.⠹ editing parse.ts · 3 tools · 42s: a spinner, the current action, the tool count and the elapsed time.◇ 3 reads · ⌕ 2 searches · ⌘ 1 shell, dim, with failures in red.✻ Baked for 29s · 6 tools · 2 files edited.⏸ queued card until it reaches the model. A queued slash command stays queued until the turn ends, which is when Claude Code runs it./prompts opens a pane listing your prompts, newest first. Pick one to scroll the transcript back to it; 1 to 9 reach the nine newest.claude-mods theme, built on the dark theme, that turns the input box border the same blue as the cards. Pick it in /theme.[!NOTE] Clicking a button (copy, fold, a row of the
/promptspane) needs Claude Code's fullscreen layout, where the terminal reports clicks. The pane's hotkeys work either way.
[!NOTE] Colours are tuned for dark themes: the inline-code chip and the prompt card's background are fixed colours, and read as dark bands on a light theme.
statusline/statusline.sh draws the status line as a rounded card: the model, the folder, the branch and its uncommitted changes, then one bar each for the context window and the 5-hour and 7-day limits, with their reset time and pace.
╭─ Opus 5.5 · claude-mods ↗ · master +5 ~4 ──────────╮
│ ctx ━━━━────── 42% 18m 04s ↑ 13.7k ↓ 2.1k │
│ 5h ━━━━━━──── 61% ↻ 2h 12m 17:26 1.1× │
│ 7d ━━━━━━━━━─ 96% ↻ 3d 5h Sat 21:00 1.8× │
╰────────────────────────────────────────────────────╯
The pace compares how fast a limit is being used with how much of its window has passed: 1.0× finishes the window right at 100%, and above that it turns yellow, then red at 1.5×.
Copy the script into ~/.claude/ and point statusLine at it in ~/.claude/settings.json:
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline.sh"
}
[!IMPORTANT] The script needs
bash,jq,gitand GNUdate. On Windows, Git Bash brings all of them exceptjq.
<!-- SECURITY -->
Mods are not sandboxed: they run with the same access to your machine as Claude Code itself. These three only draw and count; none of them runs a command, reads your files, or makes a network call. Read hooks/ before installing, as you would for any plugin.
<!-- DEVELOPMENT -->
Each mod is a plugin folder: .claude-plugin/plugin.json, hooks/hooks.json, and the hooks module hooks/register.tsx, with its tests beside it.
git clone https://github.com/healkeiser/claude-mods
cd claude-mods
claude plugin marketplace add ./
claude plugin validate ./pretty-markdown
claude plugin test ./pretty-markdown
A marketplace added from a local folder is read in place: edit a mod, run /reload-plugins, and the session picks it up with no reinstall.
<!-- LICENSE -->
MIT. See LICENSE.
[!NOTE] Claude and Claude Code are trademarks of Anthropic. These mods are an independent community project and are not affiliated with, endorsed by, or sponsored by Anthropic.
<!-- CONTACT -->
Project Link: claude-mods
<!-- GitHub profile --> <a href="https://github.com/healkeiser"> <img src="https://img.shields.io/badge/healkeiser-181717?logo=github&style=social" alt="GitHub"/></a> <!-- LinkedIn --> <a href="https://www.linkedin.com/in/valentin-beaumont"> <img src="https://img.shields.io/badge/Valentin%20Beaumont-0A66C2?logo=linkedin&style=social" alt="LinkedIn"/></a> <!-- Behance --> <a href="https://www.behance.net/el1ven"> <img src="https://img.shields.io/badge/el1ven-1769FF?logo=behance&style=social" alt="Behance"/></a> <!-- X --> <a href="https://twitter.com/valentinbeaumon"> <img src="https://img.shields.io/badge/@valentinbeaumon-1DA1F2?logo=x&style=social" alt="Twitter"/></a> <!-- Instagram --> <a href="https://www.instagram.com/val.beaumontart"> <img src="https://img.shields.io/badge/@val.beaumontart-E4405F?logo=instagram&style=social" alt="Instagram"/></a> <!-- Gumroad --> <a href="https://healkeiser.gumroad.com/subscribe"> <img src="https://img.shields.io/badge/healkeiser-36a9ae?logo=gumroad&style=social" alt="Gumroad"/></a> <!-- Gmail --> <a href="mailto:valentin.onze@gmail.com"> <img src="https://img.shields.io/badge/valentin.onze@gmail.com-D14836?logo=gmail&style=social" alt="Email"/></a> <!-- Buy me a coffee --> <a href="https://www.buymeacoffee.com/healkeiser"> <img src="https://img.shields.io/badge/Buy Me A Coffee-FFDD00?&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee"/></a>
hooks/register.tsx 103 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { Activity } from '../types'
5import { describe, elapsed, summarize } from './label'
6
7const activity = atom({ plugin: 'working-band', key: 'activity' } as const, null)
8const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
9// 8 frames a second: smooth enough for a braille spinner, light on redraws.
10const FRAME_MS = 125
11
12type TurnStats = { tools: number; files: Set<string> }
13
14export const register: Register = on => {
15 let tick: Timer | undefined
16 // This turn's counts, the last finished turn's waiting for its footer, and
17 // each footer's once drawn. Module memory: after a reload, older footers
18 // draw as the engine's.
19 let turn: TurnStats = { tools: 0, files: new Set() }
20 let finished: TurnStats | null = null
21 const footers = new Map<string, TurnStats>()
22
23 on('turn.start', async ($, e, next) => {
24 if (e.agentId !== undefined) return next(e)
25 turn = { tools: 0, files: new Set() }
26 tick?.cancel()
27 await update($, activity, (): Activity => ({ label: 'thinking', tools: 0, frames: 0 }))
28 tick = $.clock.every(FRAME_MS, () => {
29 void update($, activity, a => (a ? { ...a, frames: a.frames + 1 } : a))
30 })
31 return next(e)
32 })
33
34 // Never awaited: a failed band update must not hold up the tool call.
35 on('tool.call', ($, e, next) => {
36 turn.tools += 1
37 const path = (e as { file_path?: unknown }).file_path
38 if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(e.tool) && typeof path === 'string') turn.files.add(path)
39 update($, activity, a => (a ? { ...a, label: describe(e), tools: a.tools + 1 } : a)).catch(() => {})
40 return next(e)
41 }).catch(($, e, next) => next(e))
42
43 on('turn.complete', async ($, e, next) => {
44 if (e.agentId === undefined) {
45 finished = turn
46 tick?.cancel()
47 tick = undefined
48 await update($, activity, () => null)
49 }
50 return next(e)
51 })
52
53 // `✻ Baked for 29s · 6 tools · 2 files edited`
54 on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
55 if (!footers.has(e.requestId) && finished) {
56 footers.set(e.requestId, finished)
57 finished = null
58 }
59 const stats = footers.get(e.requestId)
60 if (!stats || stats.tools === 0) return next(e)
61
62 const { Text } = $.ui.resolve(e)
63 const parts = [
64 `${e.props.word} for ${elapsed(Math.round(e.props.durationMs / 1000))}`,
65 stats.tools === 1 ? '1 tool' : `${stats.tools} tools`,
66 ...(stats.files.size ? [`${stats.files.size} ${stats.files.size === 1 ? 'file' : 'files'} edited`] : []),
67 ]
68 return <Text dimColor>{`✻ ${parts.join(' · ')}`}</Text>
69 })
70
71 // `◇ 3 reads · ⌕ 2 searches`, dim, so the replies stand out.
72 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
73 if (e.props.isExpanded || e.props.calls.length === 0) return next(e)
74 const { Text } = $.ui.resolve(e)
75 const { text, failed } = summarize(e.props.calls)
76 const isRunning = e.props.calls.some(c => c.isRunning)
77 return (
78 <Text>
79 <Text dimColor>{` ${text}${isRunning ? ' …' : ''}`}</Text>
80 {failed ? <Text color="error">{` · ✕ ${failed} failed`}</Text> : null}
81 </Text>
82 )
83 })
84
85 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86 const a = await read($, activity)
87 if (e.props.hasSurvey || !e.props.isWorking || a === null) return next(e)
88
89 const { Box, Text } = $.ui.resolve(e)
90 const tools = a.tools === 1 ? '1 tool' : `${a.tools} tools`
91
92 return (
93 <Box flexDirection="row">
94 <Text color="claude">{SPINNER[a.frames % SPINNER.length]} </Text>
95 <Text wrap="truncate-end">
96 {a.label}
97 <Text dimColor>{` · ${tools} · ${elapsed(Math.floor((a.frames * FRAME_MS) / 1000))}`}</Text>
98 </Text>
99 </Box>
100 )
101 })
102}
103hooks/label.ts 56 lines1const base = (path: unknown) => String(path ?? '').split(/[\\/]/).pop() || 'file'
2
3// One short phrase per tool call, for the band.
4export function describe(e: { tool: string; [arg: string]: unknown }): string {
5 switch (e.tool) {
6 case 'Edit':
7 case 'MultiEdit':
8 case 'NotebookEdit':
9 return `editing ${base(e.file_path ?? e.notebook_path)}`
10 case 'Write':
11 return `writing ${base(e.file_path)}`
12 case 'Read':
13 return `reading ${base(e.file_path)}`
14 case 'Grep':
15 case 'Glob':
16 return `searching ${String(e.pattern ?? '')}`
17 case 'Bash':
18 case 'PowerShell':
19 return e.description ? String(e.description) : `running ${String(e.command ?? '').split(/\s+/)[0]}`
20 case 'Agent':
21 return `agent: ${String(e.description ?? 'working')}`
22 case 'WebFetch':
23 try { return `fetching ${new URL(String(e.url)).host}` } catch { return 'fetching' }
24 case 'WebSearch':
25 return `searching the web`
26 }
27 const mcp = /^mcp__(.+?)__(.+)$/.exec(e.tool)
28 return mcp ? `${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}` : e.tool
29}
30
31// What a folded run of tool calls did, by kind, in the order first seen.
32const KINDS: [RegExp, string, string, string][] = [
33 [/^(Read|NotebookRead)$/, '◇', 'read', 'reads'],
34 [/^(Grep|Glob|LS)$/, '⌕', 'search', 'searches'],
35 [/^(Bash|PowerShell)$/, '⌘', 'shell', 'shells'],
36 [/^(WebFetch|WebSearch)$/, '⇣', 'fetch', 'fetches'],
37 [/^mcp__/, '⧉', 'mcp call', 'mcp calls'],
38]
39
40export function summarize(calls: readonly { tool: string; isErrored: boolean }[]): { text: string; failed: number } {
41 const counts = new Map<string, { icon: string; one: string; many: string; n: number }>()
42 for (const c of calls) {
43 const kind = KINDS.find(([re]) => re.test(c.tool))
44 const [icon, one, many] = kind ? kind.slice(1) as [string, string, string] : ['·', c.tool, c.tool]
45 const got = counts.get(one) ?? { icon, one, many, n: 0 }
46 got.n += 1
47 counts.set(one, got)
48 }
49 const text = [...counts.values()].map(k => `${k.icon} ${k.n} ${k.n === 1 ? k.one : k.many}`).join(' · ')
50 return { text, failed: calls.filter(c => c.isErrored).length }
51}
52
53export function elapsed(seconds: number): string {
54 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
55}
56types/index.d.ts 8 lines1export type Activity = { label: string; tools: number; frames: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'working-band': { activity: Activity | null }
6 }
7}
8