Context window as a stacked bar above the prompt, toggled with /context-bar

Mods for Claude Code: small plugins that add to its screen and behavior. Each folder is one mod. Turn on the ones you want (see Setup) and change their settings in /config, under the mod's name.
Shows how full Claude's context window is, as a colored bar above the prompt.
/context-bar hides or shows it for this session.Shows how much of your 5-hour and weekly usage limits you've used, as two small meters under the context bar. They turn yellow at 70% and red at 90%, and you get a warning once a limit passes 90%.
Shows this session's ID in the bottom-right corner of the screen.
claude --resume <id> command.Adds a receipt to the line that closes each turn ("✻ Baked for 42s"): how many tools ran, which files were edited, the tokens used and what the turn cost.
Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.
/config sets how long a reply must be (600 characters by default).Lets you see images Claude reads, right in the conversation.
🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.Gives Claude a voice, through murmur. It speaks only when you're away from the session (another app or tab in front), so it never talks over you while you're watching.
/read reads Claude's last reply aloud./tldr-aloud gives a spoken 2–3 sentence summary of the session so far./config./hush mutes this session; run it again to unmute./config under voice.Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.
/title gets a new one right away. A resumed session gets its old title back./workspace saves and reopens whole windows of sessions: see Workspaces./goto (or ⌘⌃G) lists your open Claude sessions, the ones waiting on you first, and jumps to the one you pick. /goto next jumps straight to the one waiting longest; /goto <words> jumps to the one whose title matches./keybind add <keys> <text> makes a Ghostty key type something into Claude, like /keybind add super+ctrl+h /hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one.A workspace is a Ghostty window of Claude sessions you can put away and bring back later. It remembers every tab and split: each Claude session with its conversation, and each shell with its folder. Reopening it brings back the whole window, with every conversation picking up where it left off.
| Command | What it does |
|---|---|
/workspace save [name] | Saves this window. With no name, it keeps the name the window already had, or Claude Haiku picks one from the tabs. |
/workspace close [name] | Saves this window, then exits every Claude session in it and closes their tabs. Your current tab stays open at the shell, and shell tabs stay open. |
/workspace list | Shows your saved workspaces, newest first. Press a number or Enter to reopen one, or Tab to ✕ delete to remove it. |
/workspace open <name> | Reopens a saved workspace in a new window. Part of the name is enough. |
claude --resume, so they continue their own conversation, with the tab title they had when you saved. A tab saved without a title gets a new one from its conversation. Shell tabs open at the prompt./workspace close. Pick it up tomorrow: /workspace list, then press its number.~/.claude/workspaces/, one JSON file per workspace.CLAUDE_CODE_PLUGIN_DIRS, separated by :, in the env block of ~/.claude/settings.json: "env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/context-bar:/path/to/claude-mods/voice"
}
To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.The first time a mod controls Ghostty, macOS asks for permission. Allow it.
Editing a mod's files reloads it in running sessions.
claude plugin validate context-bar # check a mod the way Claude Code will load it
claude plugin test context-bar # run its tests
IDEAS.md has ideas for more mods.
hooks/register.tsx 71 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Segment, Snapshot } from '../types'
5import { cells, kTokens, shortName } from './bar'
6import type { BarView } from './barview'
7
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
10
11const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
12
13async function refresh($: EngineInterface) {
14 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
15 if (!breakdown) return
16 const segments: Segment[] = breakdown.categories
17 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
18 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind as Segment['kind'] }))
19 const next: Snapshot = {
20 segments,
21 used: breakdown.totalTokens,
22 max: breakdown.rawMaxTokens,
23 percent: breakdown.percentage,
24 }
25 await update($, snapshot, () => next)
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 await $.command.register({ name: 'context-bar', description: 'Toggle the context window bar above the prompt' })
31 void refresh($)
32 return next(e)
33 })
34
35 on('session.measure', async ($, e, next) => {
36 if (await read($, isOn)) await refresh($)
37 return next(e)
38 })
39
40 on('command.run', { command: 'context-bar' }, async $ => {
41 const now = !(await read($, isOn))
42 await update($, isOn, () => now)
43 if (now) await refresh($)
44 return { text: `Context bar ${now ? 'on' : 'off'}.` }
45 })
46
47 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
48 // Draw on top of what the plugins beneath draw (the turn receipt) instead of replacing it.
49 const below = await next(e)
50 const snap = await read($, snapshot)
51 if (e.props.hasSurvey || !snap || !(await read($, isOn))) return below
52 if (e.surface !== 'terminal' && e.surface !== 'desktop') return below // no Client elsewhere
53
54 const { Box, Client } = $.ui.resolve(e)
55 const label = ` ${snap.percent}% ${kTokens(snap.used)}/${kTokens(snap.max)}`
56 const counts = cells(snap.segments, Math.max(10, e.props.bodyColumns - label.length))
57 const view: BarView = {
58 runs: snap.segments.map((s, i) => ({ text: GLYPH[s.kind].repeat(counts[i] ?? 0), color: s.color })),
59 label,
60 legend: snap.segments.filter(s => s.kind === 'used').map(s => ({ color: s.color, name: shortName(s.name), tokens: kTokens(s.tokens) })),
61 }
62
63 return (
64 <Box flexDirection="column">
65 <Client key="context-bar" module="./barview.tsx" props={view} width="100%" />
66 {below}
67 </Box>
68 )
69 })
70}
71hooks/bar.ts 26 lines1import type { Segment } from '../types'
2
3// Cumulative rounding: cell counts always sum to exactly `width`.
4export function cells(segments: readonly Segment[], width: number): number[] {
5 const total = segments.reduce((sum, s) => sum + s.tokens, 0)
6 if (total <= 0 || width <= 0) return segments.map(() => 0)
7 let cumulative = 0
8 let drawn = 0
9 return segments.map(s => {
10 cumulative += s.tokens
11 const edge = Math.round((cumulative / total) * width)
12 const count = edge - drawn
13 drawn = edge
14 return count
15 })
16}
17
18export function kTokens(n: number): string {
19 return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
20}
21
22// Shorter legend names; "System prompt" → "Sys. prompt", "System tools" → "Sys. tools".
23export function shortName(name: string): string {
24 return ({ 'MCP server instructions': 'MCP', 'Memory files': 'Memory' } as Record<string, string>)[name] ?? name.replace(/^System\b/, 'Sys.')
25}
26hooks/barview.tsx 32 lines1import type { ClientModule } from 'claude-code'
2
3export type BarView = {
4 runs: { text: string; color: string }[]
5 label: string
6 legend: { color: string; name: string; tokens: string }[]
7}
8
9// Drawn by the surface so it gets pointer enter/leave (a Box hover doesn't register here).
10// While the pointer is over it, the bar's own row shows the legend instead: same height, nothing moves.
11const ContextBar: ClientModule<BarView, boolean> = ({ runs, label, legend }, surface) => {
12 const { Text } = surface.elements
13 surface.onPointer(ev => {
14 if (ev.type === 'enter' || ev.type === 'leave') surface.setState(ev.type === 'enter')
15 })
16 return surface.state ? (
17 <Text wrap="truncate">
18 {legend.map(s => (
19 <Text>
20 <Text color={s.color}>■</Text> {s.name} <Text dimColor>{s.tokens}</Text>{' '}
21 </Text>
22 ))}
23 </Text>
24 ) : (
25 <Text wrap="truncate">
26 {runs.map(r => <Text color={r.color}>{r.text}</Text>)}
27 <Text dimColor>{label}</Text>
28 </Text>
29 )
30}
31export default ContextBar
32types/index.d.ts 9 lines1export type Segment = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2export type Snapshot = { segments: Segment[]; used: number; max: number; percent: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'context-bar': { snapshot: Snapshot | null; isOn: boolean }
7 }
8}
9