A one-line TL;DR under Claude's long replies, written by Claude Haiku; the voice mod speaks the same line

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 51 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { cleanLine, tldrPrompt } from './line'
5
6// Shown under the turn's closing line ("✻ Cooked for 11s"). turn-receipt reads it too, since it redraws that line.
7const byDuration = atom({ plugin: 'tldr', key: 'byDuration' } as const, {})
8
9export const register: Register = (on, options) => {
10 const minChars = typeof options.minChars === 'number' && options.minChars > 0 ? options.minChars : 600
11 let request = ''
12
13 on('prompt.submit', ($, e, next) => {
14 if (e.origin.kind !== 'task-notification') request = e.text
15 return next(e)
16 }).catch(($, e, next) => next(e))
17
18 on('turn.complete', async ($, e, next) => {
19 const result = await next(e)
20 if (e.agentId) return result
21 const isLong = e.reason === 'answer' && e.answer.length >= minChars
22 let text = ''
23 if (isLong) {
24 const r = await $.model.complete({ model: 'haiku', prompt: tldrPrompt(request, e.answer), maxTokens: 80, timeoutMs: 8000 })
25 text = r.isAnswered ? cleanLine(r.text) : ''
26 }
27 if (text) {
28 // ponytail: two turns with the same durationMs share a TL;DR; key by a real turn id if the line ever carries one.
29 await update($, byDuration, all => Object.fromEntries([...Object.entries(all), [String(e.durationMs), text]].slice(-500)))
30 }
31 // Published for other mods (voice speaks this same line). Empty text: nothing to wait for. Only tldr writes it.
32 await $.state.set({ plugin: 'tldr', key: 'line' }, { turnId: e.turnId, text })
33 // Not returned as text beneath the answer: Claude Code labels that row with every mod on the event.
34 return result
35 })
36
37 // Under the closing line. When turn-receipt drew that line it already added the TL;DR, so it isn't added twice.
38 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
39 const line = await next(e)
40 const text = (await read($, byDuration))[String(e.props.durationMs)]
41 if (!text || JSON.stringify(line).includes('TL;DR')) return line
42 const { Box, Text } = $.ui.resolve(e)
43 return (
44 <Box flexDirection="column">
45 {line}
46 <Text dimColor> TL;DR: {text}</Text>
47 </Box>
48 )
49 })
50}
51hooks/line.ts 24 lines1/** The prompt for one line that works both under the reply and spoken aloud. */
2export function tldrPrompt(request: string, answer: string): string {
3 return [
4 "A coding assistant just finished replying. Write its TL;DR: ONE plain sentence of at most 20 words",
5 "saying what was done or concluded, and anything the developer must act on. It is shown under the",
6 'reply and read aloud, so: no markdown, code, file paths, quotes or a "TL;DR" prefix.',
7 '',
8 `Request: ${request.slice(0, 1500)}`,
9 `Reply: ${answer.slice(0, 4000)}`,
10 ].join('\n')
11}
12
13/** The model's line, cleaned to one safe sentence; empty when unusable. */
14export function cleanLine(reply: string): string {
15 const line = reply
16 .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
17 .replace(/^\s*(tl;?dr:?\s*)/i, '')
18 .replace(/[*_`#>]/g, '')
19 .replace(/\s+/g, ' ')
20 .trim()
21 return line.length >= 3 && line.length <= 240 ? line : ''
22}
23
24types/index.d.ts 12 lines1/** This turn's TL;DR; empty `text` when the reply was too short for one. Other mods (voice) read it. */
2export type TldrLine = { turnId: string; text: string }
3
4/** Each turn's TL;DR keyed by its duration in ms, the one thing the turn's closing line shares with turn.complete. */
5export type TldrByDuration = Record<string, string>
6
7declare module 'claude-code' {
8 interface PluginState {
9 tldr: { line: TldrLine | null; byDuration: TldrByDuration }
10 }
11}
12