Sidebar of every Claude Code session across workspaces, folded per folder, searchable; a click resumes one

Seven 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, animated diagrams Claude can draw in the transcript, and a sidebar for finding and resuming sessions.
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 |
session-browser | A sidebar of every session from every folder, folded per folder and searchable; a click resumes one |
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:~/.claude/mods/session-browser"
}
}
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) |
Click ☰ sessions at the right of the row above the prompt, or run /sessions, to open a sidebar of every Claude Code session on the machine. In fullscreen mode it docks beside the transcript; Esc closes it.
now, 12m, 3h, 4d): green within the hour, plain within the day, dim after that. The current session is green with ●./rename title wins over the title Claude Code generates. A session with neither shows as untitled <id>.⌕ field to filter by title and folder path. Every word must match, and folders with a match open by themselves./resume <id>. One from another folder copies cd '<folder>' && claude --resume <id> to the clipboard, because a session cannot change its working directory.The sidebar reads ~/.claude/projects/*/*.jsonl each time it opens (↻ reads again). Transcripts can be larger than the 4 MiB a mod may read at once, so two grep passes pull out each session's titles and working directory. Subagent transcripts are left out.
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 252 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Session } from '../types'
5
6const PANE = 'sessions'
7const sessions = atom({ plugin: 'session-browser', key: 'sessions' } as const, [])
8const open = atom({ plugin: 'session-browser', key: 'open' } as const, {})
9const query = atom({ plugin: 'session-browser', key: 'query' } as const, '')
10const isLoading = atom({ plugin: 'session-browser', key: 'isLoading' } as const, false)
11
12// The tool-cards palette, so the sidebar reads as the same family.
13const C = {
14 accent: '#E8A15A',
15 folder: '#E8A15A',
16 title: '#D7DCE4',
17 here: '#7EE787',
18 fresh: '#9FCB78',
19 muted: '#7F8796',
20 frame: '#3B414D',
21}
22
23// The dock asks for this many columns; the person can drag it wider.
24const DOCK_COLUMNS = 52
25const TITLE = /"(customTitle|aiTitle)":"(?:[^"\\]|\\.)*"/.source
26const CWD = /"cwd":"[^"]*"/.source
27
28type Api = EngineInterface
29
30function age(ms: number, now: number) {
31 const m = Math.max(0, Math.round((now - ms) / 60_000))
32 if (m < 1) return 'now'
33 if (m < 60) return `${m}m`
34 if (m < 48 * 60) return `${Math.round(m / 60)}h`
35 return `${Math.round(m / 1440)}d`
36}
37
38const cut = (text: string, room: number) => (text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`)
39// Spaces that push the right-hand text to the row's end; a Button holds only text, so no flex box can.
40const gap = (used: number, right: string, room: number) => ' '.repeat(Math.max(1, room - used - right.length))
41// Within the hour green, within the day plain, older dim.
42const ageColor = (ms: number, now: number) => (now - ms < 3_600_000 ? C.fresh : now - ms < 86_400_000 ? C.title : C.muted)
43
44// grep -H prints `<file>:<match>`; a transcript path ends in .jsonl, so that splits it.
45function byFile(stdout: string) {
46 const out: [string, Record<string, string>][] = []
47 for (const line of stdout.split('\n')) {
48 const at = line.indexOf('.jsonl:')
49 if (at < 0) continue
50 try {
51 out.push([line.slice(0, at + 6), JSON.parse(`{${line.slice(at + 7)}}`) as Record<string, string>])
52 } catch {}
53 }
54 return out
55}
56
57// Every main-thread transcript under ~/.claude/projects, titled by /rename, else by its AI title.
58// Transcripts run past $.fs.read's 4 MiB, so grep pulls the two fields out of all of them in one pass.
59async function scan($: Api): Promise<Session[]> {
60 const root = `${await $.env.get('HOME')}/.claude/projects`
61 const dirs = (await $.fs.list(root)).filter(d => d.kind === 'dir')
62 const listed = await Promise.all(dirs.map(async d => ({ d, entries: await $.fs.list(`${root}/${d.name}`) })))
63 const files = new Map<string, number>()
64 for (const { d, entries } of listed)
65 for (const f of entries) if (f.kind === 'file' && f.name.endsWith('.jsonl')) files.set(`${root}/${d.name}/${f.name}`, f.mtimeMs)
66 if (files.size === 0) return []
67
68 const paths = [...files.keys()]
69 const [titles, cwds] = await Promise.all([
70 $.process.run(['grep', '-H', '-o', '-E', TITLE, ...paths]),
71 $.process.run(['grep', '-H', '-m1', '-o', '-E', CWD, ...paths]),
72 ])
73 const custom = new Map<string, string>()
74 const ai = new Map<string, string>()
75 for (const [file, rec] of byFile(titles.stdout)) {
76 if (rec.customTitle) custom.set(file, rec.customTitle)
77 if (rec.aiTitle) ai.set(file, rec.aiTitle)
78 }
79 const cwd = new Map(byFile(cwds.stdout).map(([file, rec]) => [file, rec.cwd ?? '']))
80
81 return paths
82 .filter(p => cwd.get(p))
83 .map(p => {
84 const id = p.slice(p.lastIndexOf('/') + 1, -'.jsonl'.length)
85 return { id, cwd: cwd.get(p)!, title: custom.get(p) ?? ai.get(p) ?? `untitled ${id.slice(0, 8)}`, mtimeMs: files.get(p)! }
86 })
87 .sort((a, b) => b.mtimeMs - a.mtimeMs)
88}
89
90async function refresh($: Api) {
91 await update($, isLoading, () => true)
92 try {
93 const found = await scan($)
94 await update($, sessions, () => found)
95 } catch (err) {
96 $.ui.toast(`Sessions: could not read ~/.claude/projects (${err instanceof Error ? err.message : String(err)})`)
97 } finally {
98 await update($, isLoading, () => false)
99 }
100}
101
102async function openPane($: Api) {
103 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true, columns: DOCK_COLUMNS })
104 await refresh($)
105}
106
107// The same folder resumes in place; another folder needs its own cwd, so the command goes to the clipboard.
108async function resume($: Api, s: Session, surface?: Parameters<Api['ui']['copy']>[0]['surface']) {
109 const [here, cwd] = await Promise.all([$.session.id(), $.session.cwd()])
110 if (s.id === here) {
111 $.ui.toast('That is this session.')
112 return
113 }
114 if (s.cwd === cwd) {
115 await $.ui.close({ id: PANE })
116 await $.command.run({ command: 'resume', args: s.id })
117 return
118 }
119 const line = `cd '${s.cwd.replace(/'/g, `'\\''`)}' && claude --resume ${s.id}`
120 const { isCopied } = await $.ui.copy({ text: line, ...(surface ? { surface } : {}) })
121 $.ui.toast(isCopied ? `Copied: ${line}` : `Run in a new tab: ${line}`)
122}
123
124const tilde = (path: string, home: string) => (home && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
125
126export const register: Register = on => {
127 on('session.start', async ($, e, next) => {
128 await $.command.register({ name: 'sessions', description: 'Browse and resume Claude Code sessions from every workspace' })
129 return next(e)
130 })
131
132 on('command.run', { command: 'sessions' }, async $ => {
133 await openPane($)
134 return { text: 'Sessions sidebar opened.' }
135 })
136
137 // The button sits at the right of the band above the prompt, beside whatever another mod draws there.
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 if (e.props.hasSurvey) return next(e)
140 const below = await next(e)
141 const { Box, Button } = $.ui.resolve(e)
142 return (
143 <Box>
144 <Box flexGrow={1}>{below ?? null}</Box>
145 <Button key="open-sessions" plain dimColor label="☰ sessions" onPress={() => void openPane($)} />
146 </Box>
147 )
148 })
149
150 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
151 const els = $.ui.resolve(e)
152 const { Box, Button, Text } = els
153 // Mobile draws no text field; there the list shows without search.
154 const Input = 'Input' in els ? els.Input : undefined
155 const [all, folded, q, loading, here, home, now] = await Promise.all([
156 read($, sessions),
157 read($, open),
158 read($, query),
159 read($, isLoading),
160 $.session.id(),
161 $.env.get('HOME'),
162 $.clock.now(),
163 ])
164 const room = Math.max(20, e.props.bodyColumns)
165 const words = q.toLowerCase().split(/\s+/).filter(Boolean)
166 const shown = words.length === 0 ? all : all.filter(s => words.every(w => `${s.title} ${s.cwd}`.toLowerCase().includes(w)))
167
168 // Folders in order of their newest session, as `shown` is sorted newest first.
169 const groups = new Map<string, Session[]>()
170 for (const s of shown) groups.set(s.cwd, [...(groups.get(s.cwd) ?? []), s])
171
172 const rule = (key: string) => (
173 <Text key={key} color={C.frame}>
174 {'─'.repeat(room)}
175 </Text>
176 )
177
178 return (
179 <Box flexDirection="column">
180 <Box key="head" gap={1}>
181 <Box flexGrow={1}>
182 <Text color={C.accent} bold>
183 ◆ Sessions
184 </Text>
185 </Box>
186 <Text color={C.muted}>{loading ? 'reading…' : `${shown.length} · ${groups.size} folders`}</Text>
187 <Button key="refresh" plain dimColor label="↻" onPress={() => void refresh($)} />
188 </Box>
189 {rule('rule-head')}
190 {Input ? (
191 <Input
192 key="query"
193 label="⌕"
194 placeholder="search titles and folders"
195 value={q}
196 autoFocus
197 onInput={(v: string) => void update($, query, () => v)}
198 onSubmit={(v: string) => void update($, query, () => v)}
199 />
200 ) : null}
201 {Input ? rule('rule-search') : null}
202 {[...groups].map(([cwd, list]) => {
203 // A search opens every folder it matched in.
204 const isOpen = words.length > 0 || folded[cwd] === true
205 // The folder's own name leads; where it sits follows, dim.
206 const path = tilde(cwd, home ?? '')
207 const slash = path.lastIndexOf('/')
208 const count = String(list.length)
209 const name = cut(path.slice(slash + 1) || path, room - count.length - 4)
210 const parent = cut(slash > 0 ? path.slice(0, slash) : '', Math.max(0, room - name.length - count.length - 6))
211 const used = 2 + name.length + (parent ? parent.length + 2 : 0)
212 return (
213 <Box key={`g:${cwd}`} flexDirection="column">
214 <Button key={`folder:${cwd}`} plain onPress={() => void update($, open, m => ({ ...m, [cwd]: !isOpen }))}>
215 <Text color={C.muted}>{isOpen ? '▾ ' : '▸ '}</Text>
216 <Text color={C.folder} bold>
217 {name}
218 </Text>
219 <Text color={C.muted}>{parent ? ` ${parent}` : ''}</Text>
220 <Text color={C.muted}>{`${gap(used, count, room)}${count}`}</Text>
221 </Button>
222 {isOpen
223 ? list.map((s, i) => {
224 const when = age(s.mtimeMs, now)
225 const isHere = s.id === here
226 const guide = i === list.length - 1 ? ' └─ ' : ' ├─ '
227 const mark = isHere ? '● ' : ''
228 const title = cut(s.title, room - guide.length - mark.length - when.length - 1)
229 return (
230 <Button key={`session:${s.id}`} plain onPress={() => void resume($, s, e.surface)}>
231 <Text color={C.frame}>{guide}</Text>
232 <Text color={C.here}>{mark}</Text>
233 <Text color={isHere ? C.here : C.title} bold={isHere}>
234 {title}
235 </Text>
236 <Text color={ageColor(s.mtimeMs, now)}>{`${gap(guide.length + mark.length + title.length, when, room)}${when}`}</Text>
237 </Button>
238 )
239 })
240 : null}
241 {isOpen ? <Text key={`space:${cwd}`}> </Text> : null}
242 </Box>
243 )
244 })}
245 {!loading && shown.length === 0 ? (
246 <Text color={C.muted}>{words.length > 0 ? ` No session matches “${q.trim()}”.` : ' No sessions found.'}</Text>
247 ) : null}
248 </Box>
249 )
250 })
251}
252types/index.d.ts 18 lines1export type Session = {
2 id: string
3 cwd: string
4 title: string
5 mtimeMs: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'session-browser': {
11 sessions: Session[]
12 open: Record<string, boolean>
13 query: string
14 isLoading: boolean
15 }
16 }
17}
18