Shows the current Git branch, context, and account usage above the prompt with detailed session information

A collection of personal Claude Code mods by Brad Traversy. These mods add useful session information, interface customizations, safety demonstrations, and verification tools to Claude Code.
Every mod is a small, independent Claude Code plugin written in TypeScript. Install only the ones you want, or run all eight together.
[!IMPORTANT] Mods run inside Claude Code with your user permissions. They can see session events, start processes, access files, and make network requests. Read the source before installing any mod, including these.
A mod is a Claude Code plugin with JavaScript or TypeScript event handlers. Claude Code calls those handlers when something happens, such as:
A handler can observe the event, change it, or handle it completely. That makes mods different from normal settings hooks. A mod can draw interface elements, replace existing tool rows, register slash commands, share state between handlers, and intercept a tool call before it runs.
These mods work in:
Visual elements do not appear in the VS Code chat panel, claude -p, Agent SDK sessions, or cloud sessions. Plugins are not available in WSL sessions opened through the desktop app.
gh auth login, only if you want Verification Ledger to display remote GitHub Actions runsCheck your terminal version:
claude --version
In the desktop Code tab, enter /status and check the Claude Code version.
Run this in your shell:
claude plugin marketplace add bradtraversy/claude-mods
You can also enter this in a Claude Code session:
/plugin marketplace add bradtraversy/claude-mods
Install one mod:
claude plugin install session-hud@traversy-mods
Install all eight:
claude plugin install session-hud@traversy-mods
claude plugin install impact-map@traversy-mods
claude plugin install tool-cards@traversy-mods
claude plugin install safe-shell@traversy-mods
claude plugin install quality-console@traversy-mods
claude plugin install verification-ledger@traversy-mods
claude plugin install agent-pulse@traversy-mods
claude plugin install work-queue@traversy-mods
Inside an interactive session, the same commands start with /plugin install instead of claude plugin install.
Start a new Claude Code session. If a session was already open when you installed or updated a mod, enter:
/reload-plugins
Run /plugin and open the Installed tab to confirm that the mods are enabled.
Claude Code in the terminal and local sessions in the desktop Code tab share user-scoped plugin settings. Install a mod once at user scope and it is available in both places.
For the desktop app:
/reload-plugins, or start a new session after installation./plugin and confirm the mod is listed as active.You can also use the desktop plugin manager. Click the plus button beside the prompt, choose Plugins, and use Manage plugins to enable or disable an installed mod.
| Mod | What it does | How to open or test it |
|---|---|---|
| Session HUD | Shows the current Git branch, context, and weekly usage with detailed session information | Switch branches or click Details beside the context meter |
| Impact Map | Tracks touched files and opens current Git diffs | Select an edited or created file in the Impact Map |
| Tool Cards | Replaces normal tool-use rows with compact status cards | Ask Claude to read a file or run a command |
| Safe Shell | Demonstrates a confirmation gate for a marked Bash command | Ask Claude to run echo __SAFE_SHELL_DEMO__ |
| Quality Console | Validates a local mod from inside Claude Code | Enter /mod-check <path> |
| Verification Ledger | Tracks local checks, Git state, and GitHub Actions | Enter /checks |
| Agent Pulse | Shows live subagent status, activity, elapsed time, and recent transcripts | Ask Claude to use a subagent or enter /agents |
| Work Queue | Groups session tasks by working, blocked, ready, and done | Ask Claude to create tasks or enter /work-queue |
Session HUD draws a compact band above the prompt with:
Working branches appear in green. main and master appear in yellow as a reminder that you are on the default branch. Detached HEAD states are also yellow, and folders outside a Git repository say so directly. Session HUD checks every five seconds, so it also catches branch changes made in the built-in terminal or another terminal. Branches changed by a Claude Bash tool refresh immediately.
The compact Week label shows overall seven-day account usage. Click Details beside it to open Session Details. The pane shows:
Claude Code also counts the current context and shows:
The full breakdown runs only when you click Details or press Refresh. It does not poll in the background. Claude Code provides a reliable total for Messages, but it does not provide a reliable token count for every individual message.
The context percentage uses a configurable personal target. It does not change Claude's actual context window or automatic compaction behavior.
Open Session Details and select Change beside the current target. Enter a value such as 200k, 300k, 750k, or 1m, then press Enter to save it. You can also open Claude Code's /config menu and change Context target there.
The setting is saved with the plugin and applies to future sessions. The default is 300k, and accepted values range from 10k through 10m.
The meter is green below 70 percent, yellow from 70 through 89 percent, and red at 90 percent or higher. Its visual fill stops at 100 percent, but the numeric percentage can go higher.
session-hud.Week label shows your weekly percentage.Details beside the progress bar.Change, enter another context target, and confirm that the percentage updates after the mod reloads.Source: session-hud/hooks/register.tsx
Impact Map opens a pane that records the most recent files Claude touched during the current session.
It labels each file as:
READ when Claude uses the Read toolEDITED when Claude uses the Edit toolCREATED when Claude uses the Write toolThe pane keeps the 14 most recent unique file paths. Reading or changing the same file again moves it to the newest position.
Edited and created files are clickable. Selecting one opens a Diff pane with Claude Code's native unified-diff highlighting. Tracked files use the current working-tree diff against HEAD. Untracked files are shown as newly added files.
The Diff pane reflects the file's current Git state, not a private history of only Claude's edits. If you or another tool changed the same file, those changes appear too. Read-only files are not clickable because they have no change to display.
impact-map./impact-map to reopen it.Claude Code has one docked pane area. If Verification Ledger is also open, the two panes appear as tabs in that area rather than side by side.
Source: impact-map/hooks/register.tsx
Tool Cards replaces Claude Code's standard tool-use row with a bordered card that emphasizes the current state, tool name, and target.
Possible states are:
RUNNINGDONEFAILEDSTOPPEDFor file tools, the card displays the file path. For Bash, it displays the command. It also handles tools that expose a path or pattern field.
tool-cards.RUNNING to DONE.FAILED state.The green DONE label is part of this mod. Disable Tool Cards from /plugin to return to Claude Code's normal tool rows.
Source: tool-cards/hooks/register.tsx
Safe Shell is a harmless demonstration of a tool.check handler. It shows how a mod can stop before a Bash command runs and ask the user to approve or cancel it.
This version looks only for the marker:
__SAFE_SHELL_DEMO__
It does not attempt to identify real dangerous commands. That limited behavior is intentional, so the demo cannot accidentally become a false promise of shell security.
safe-shell. echo __SAFE_SHELL_DEMO__
git status and confirm that Safe Shell does not interfere.Source: safe-shell/hooks/register.ts
Quality Console adds a /mod-check command that runs Claude Code's official static validator against a local mod directory.
Use it from a Claude Code session:
/mod-check /absolute/path/to/a-mod
If you omit the path, it validates the current working directory:
/mod-check
The command displays PASS, FAIL, or ERROR followed by the validator output. Validation checks the manifest, hook events, matchers, API calls, imports, and statically detectable capabilities. It does not execute the mod.
quality-console./mod-check with the absolute path to one of the mod directories.PASS.Example:
/mod-check /path/to/claude-mods/session-hud
Source: quality-console/hooks/register.ts
Verification Ledger opens a Checks pane that combines three kinds of evidence:
Open it with:
/checks
The pane does not open automatically, so it does not compete with Impact Map until you ask for it. When both panes are open, Claude Code places them in tabs within the same docked pane area.
The ledger recognizes common commands in these categories:
npm run verify, pnpm verify, yarn verify, bun verify, and ./scripts/verifycargo test, and go testtsc --noEmit, Mypy, and Pyrightcargo build, and go buildThe command must be run by Claude through its Bash tool. A command run manually in a separate terminal is not a Claude Code event, so the mod cannot observe it.
Verification Ledger stores the latest result in each category for the current session.
PASS means the recognized command completed successfully.FAIL means it returned an error.STALE means files, the current commit, or the recorded session revision changed after the check ran.Local results reset when the mod reloads or a new session starts. The ledger is a live session display, not a permanent audit database.
Remote checks require the GitHub CLI:
gh --version
gh auth status
If needed, authenticate it:
gh auth login
While the Checks pane is open, the mod runs this read-only query for the current commit every 15 seconds:
gh run list --commit <commit-sha> --limit 8 --json name,status,conclusion,url,headSha,workflowName
The pane displays workflow links and the states RUNNING, PASS, FAIL, or SKIPPED. It also shows a toast when a previously running workflow passes or fails.
If gh is missing, not authenticated, or the repository is not hosted on GitHub, the remote section reports that GitHub CI is unavailable. Local checks and Git state still work.
Open verification-ledger/hooks/config.ts:
export const CI_POLL_INTERVAL_MS = 15_000
export const MAX_CI_RUNS = 8
Change the values to control how often the open pane refreshes and how many workflow runs it displays.
verification-ledger in a Git repository./checks.Tests appears with PASS or FAIL./checks and confirm that the earlier result is STALE.RUNNING to its final state.Source: verification-ledger/hooks/register.tsx
Agent Pulse opens an Agents pane when the first subagent starts in the current session. It gives each agent a compact card with:
The pane does not open when a session has no subagents. It also does not take keyboard focus when it opens automatically. Enter /agents whenever you want to open it manually or bring it back after closing it.
Select View on an agent card to replace the list with a compact detail view. The detail view shows the agent type, status, elapsed time, parent agent when present, and up to eight recent transcript messages. Select Back to return to the full list.
Agent Pulse keeps finished agents in the pane for the rest of the current session, even when Claude Code no longer includes them in its live agent list. The history resets when the mod reloads or a new session starts.
Possible states are:
PENDING before work beginsRUNNING while the agent is workingWAITING when it is waiting on another actionIDLE when a teammate is available for more workCOMPLETED after a successful resultFAILED after an error or refusalKILLED after an interrupted runAgent Pulse uses only Claude Code's agent, session, clock, command, and interface APIs. It does not call a model, start a process, read project files, make a network request, or save agent transcripts outside the current session.
Claude Code may deny transcript access for an agent running in another process, a remote workflow agent, or an agent whose saved transcript is no longer available. The card still shows any status and activity that Claude Code reports, and the detail view explains when its transcript cannot be read.
agent-pulse./agents and confirm that the empty state says there are no subagents in the session.Agents pane opens without taking focus.View and confirm that the recent prompt, response, and tool names appear.Back to return to the agent list.Source: agent-pulse/hooks/register.tsx
Work Queue opens a Tasks pane when Claude creates the first task in the current session. It turns Claude's task state into four focused groups:
Working for tasks in progressBlocked for pending tasks with unfinished dependenciesReady for pending tasks that can startDone for completed tasksEach row shows the task subject, owner when one is assigned, and how long it has been in its current state. Select View to see the full description, owner, creation and update times, tasks that block it, and tasks that depend on it. Dependencies are clickable, so you can move through the work graph without leaving the pane.
The pane does not open in an empty session and does not take keyboard focus when it opens automatically. Enter /work-queue to open it manually or bring it back after closing it.
Work Queue observes TaskCreate, TaskUpdate, TaskList, and TaskGet tool calls. It also observes Claude Code's task-created and task-completed lifecycle events, which catches task activity that did not pass through those tools. It never creates, updates, completes, or deletes a task itself.
Task history lives only in memory for the current session. Reloading the mod or starting a new session clears the pane. The mod does not call a model, start a process, read project files, make a network request, or persist task data.
Claude Code does not deliver classic task lifecycle events to user-installed plugins when an organization manages the installation. Normal task tool calls still appear, but a task created in another process may be missing if it produces only a lifecycle event.
work-queue./work-queue and confirm that the empty state says there are no tasks in the session.Working, the dependent task under Blocked, and the remaining task under Ready.View on the blocked task and confirm that the API task appears under Blocked by.Done while the dependent task moves to Ready.Source: work-queue/hooks/register.tsx
The mods have separate responsibilities, so their interface elements do not duplicate each other:
/mod-check.Impact Map, Verification Ledger, Agent Pulse, and Work Queue share Claude Code's single docked pane area as separate tabs. Agent Pulse shows who is working, while Work Queue shows what work exists and what is blocked. All other mods use different interface regions or event types.
You do not need Node.js, a bundler, or a dependency installation. Claude Code loads TypeScript mod files directly and supplies the test runner.
git clone https://github.com/bradtraversy/claude-mods.git
cd claude-mods
claude plugin validate .
claude plugin validate ./session-hud
claude plugin validate ./impact-map
claude plugin validate ./tool-cards
claude plugin validate ./safe-shell
claude plugin validate ./quality-console
claude plugin validate ./verification-ledger
claude plugin validate ./agent-pulse
claude plugin validate ./work-queue
Validation reads the mod without executing it. Review the reported hooks: and calls: lines to see what it handles and which Claude Code APIs it uses.
claude plugin test ./session-hud
claude plugin test ./impact-map
claude plugin test ./tool-cards
claude plugin test ./safe-shell
claude plugin test ./quality-console
claude plugin test ./verification-ledger
claude plugin test ./agent-pulse
claude plugin test ./work-queue
The interface tests mount each visual mod on simulated terminal and desktop surfaces.
claude --plugin-dir ./session-hud
Claude Code watches a directory loaded with --plugin-dir and reloads the hooks module after changes. Run /plugin to confirm that the mod loaded.
You can load multiple development directories by repeating the flag:
claude \
--plugin-dir ./session-hud \
--plugin-dir ./impact-map \
--plugin-dir ./tool-cards
Register the clone itself as a local marketplace:
claude plugin marketplace add /absolute/path/to/claude-mods
Then install any mod from traversy-mods. A marketplace added from a local directory reads these relative plugin directories in place, so edits appear after a new session starts or after /reload-plugins.
This is the easiest development setup for sharing the exact same working directory between terminal sessions and local desktop Code sessions.
Every directory follows the same basic structure:
mod-name/
├── .claude-plugin/
│ ├── plugin.json
│ └── types/
│ ├── .gitignore
│ └── tsconfig.json
├── hooks/
│ ├── hooks.json
│ ├── config.ts # only when the mod has user-editable settings
│ └── register.tsx # register.ts when JSX is not needed
├── tests/
│ └── mod-name.test.ts
└── tsconfig.json
plugin.jsonThe plugin manifest provides the mod name, version, description, and author.
hooks.jsonThis file points Claude Code to the TypeScript module that registers the event handlers.
register.ts or register.tsxThe module imports the Register type from claude-code and exports a register function:
import type { Register } from 'claude-code'
export const register: Register = on => {
on('session.start', ($, event, next) => {
return next(event)
})
}
The Register type comes from declarations generated by Claude Code in .claude-plugin/types/. Those declarations also provide types for $, event, and next, which removes the red lines a
hooks/register.tsx 699 lines1import type {
2 ContextMcpTool,
3 EngineInterface,
4 Register,
5 SessionContextBreakdown,
6 SessionRateLimit,
7 SessionUsage,
8} from 'claude-code'
9import {
10 DEFAULT_CONTEXT_MAX_TOKENS,
11 MAX_CONTEXT_MAX_TOKENS,
12 MIN_CONTEXT_MAX_TOKENS,
13} from './config.ts'
14
15const PANE = 'context-breakdown'
16const MCP_SERVER_LIMIT = 5
17const MCP_TOOLS_PER_PAGE = 10
18const BRANCH_REFRESH_INTERVAL_MS = 5_000
19
20let breakdown: SessionContextBreakdown | null = null
21let breakdownError = ''
22let breakdownLoading = false
23let detailUsage: SessionUsage | null = null
24let selectedMcpServer: string | null = null
25let mcpPage = 0
26let branchRefreshStarted = false
27let branchRefreshing = false
28let editingContextTarget = false
29let contextTargetDraft = ''
30let contextTargetError = ''
31
32type BranchState = {
33 kind: 'loading' | 'branch' | 'default' | 'detached' | 'none' | 'error'
34 label: string
35}
36
37let branchState: BranchState = {
38 kind: 'loading',
39 label: 'Checking branch',
40}
41
42type McpServerGroup = {
43 name: string
44 loadedTokens: number
45 loadedTools: number
46 deferredTools: number
47 tools: ContextMcpTool[]
48}
49
50function meter(percent: number, width: number): { filled: string; remaining: string } {
51 const safePercent = Math.min(Math.max(percent, 0), 100)
52 const rounded = Math.round((safePercent / 100) * width)
53 const filled = safePercent > 0 ? Math.max(1, rounded) : 0
54 return {
55 filled: '█'.repeat(filled),
56 remaining: '░'.repeat(width - filled),
57 }
58}
59
60function rateLimitLabel(kind: string): string {
61 if (kind === 'five_hour') return '5h'
62 if (kind === 'seven_day') return '7d'
63 return kind.replaceAll('_', ' ')
64}
65
66function rateLimitTitle(limit: SessionRateLimit): string {
67 if (limit.kind === 'five_hour') return 'Five-hour usage'
68 if (limit.kind === 'seven_day') return 'Weekly usage'
69 return rateLimitLabel(limit.kind)
70}
71
72function resetLabel(resetsAt?: string): string {
73 if (!resetsAt) return ''
74
75 const date = new Date(resetsAt)
76 if (Number.isNaN(date.getTime())) return ''
77
78 return `Resets ${date.toLocaleString([], {
79 weekday: 'short',
80 hour: 'numeric',
81 minute: '2-digit',
82 })}`
83}
84
85function tokenLabel(tokens: number): string {
86 if (tokens >= 1_000_000) {
87 const millions = tokens / 1_000_000
88 return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}m`
89 }
90
91 return `${Math.round(tokens / 1_000)}k`
92}
93
94function detailTokenLabel(tokens: number): string {
95 if (tokens >= 1_000_000) {
96 const millions = tokens / 1_000_000
97 return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}m`
98 }
99
100 if (tokens >= 10_000) return `${Math.round(tokens / 1_000)}k`
101 if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`
102 return tokens.toLocaleString()
103}
104
105function contextMaxTokens(value: unknown): number {
106 if (typeof value !== 'number' || !Number.isFinite(value)) {
107 return DEFAULT_CONTEXT_MAX_TOKENS
108 }
109
110 return Math.min(
111 MAX_CONTEXT_MAX_TOKENS,
112 Math.max(MIN_CONTEXT_MAX_TOKENS, Math.round(value)),
113 )
114}
115
116function parseContextTarget(answer: string): number | null {
117 const normalized = answer.trim().toLowerCase().replaceAll(',', '').replaceAll('_', '')
118 const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([km])?$/)
119 if (!match) return null
120
121 const amount = Number(match[1])
122 const multiplier = match[2] === 'm' ? 1_000_000 : match[2] === 'k' ? 1_000 : 1
123 const tokens = Math.round(amount * multiplier)
124
125 if (tokens < MIN_CONTEXT_MAX_TOKENS || tokens > MAX_CONTEXT_MAX_TOKENS) return null
126 return tokens
127}
128
129async function saveContextTarget($: EngineInterface, answer: string): Promise<void> {
130 contextTargetDraft = answer
131 contextTargetError = ''
132
133 try {
134 const target = parseContextTarget(answer)
135
136 if (!target) {
137 contextTargetError = 'Enter 10k to 10m, such as 300k or 1m.'
138 $.ui.invalidate('ui.render')
139 return
140 }
141
142 const result = await $.config.set({
143 key: 'session-hud.context_max_tokens',
144 value: target,
145 })
146
147 if ('deny' in result && result.deny) {
148 contextTargetError = result.deny
149 $.ui.invalidate('ui.render')
150 return
151 }
152
153 editingContextTarget = false
154 contextTargetError = ''
155 $.ui.invalidate('ui.render')
156 } catch {
157 contextTargetError = 'The context target could not be saved.'
158 $.ui.invalidate('ui.render')
159 }
160}
161
162function percentage(tokens: number, total: number): string {
163 if (total <= 0) return '0%'
164 const value = (tokens / total) * 100
165 return `${value >= 10 ? Math.round(value) : value.toFixed(1)}%`
166}
167
168function usageColor(percent: number): 'success' | 'warning' | 'error' {
169 if (percent >= 90) return 'error'
170 if (percent >= 70) return 'warning'
171 return 'success'
172}
173
174function branchColor(kind: BranchState['kind']): 'success' | 'warning' | 'inactive' {
175 if (kind === 'branch') return 'success'
176 if (kind === 'default' || kind === 'detached') return 'warning'
177 return 'inactive'
178}
179
180function compactBranchName(name: string, maxLength = 36): string {
181 if (name.length <= maxLength) return name
182
183 const tailLength = 10
184 const headLength = maxLength - tailLength - 3
185 return `${name.slice(0, headLength)}...${name.slice(-tailLength)}`
186}
187
188function updateBranch($: EngineInterface, nextState: BranchState): void {
189 if (branchState.kind === nextState.kind && branchState.label === nextState.label) return
190
191 branchState = nextState
192 $.ui.invalidate('ui.render')
193}
194
195async function refreshBranch($: EngineInterface, cwd?: string): Promise<void> {
196 if (branchRefreshing) return
197
198 branchRefreshing = true
199
200 try {
201 const directory = cwd ?? await $.session.cwd()
202 const current = await $.process.run(['git', 'branch', '--show-current'], {
203 cwd: directory,
204 timeoutMs: 5_000,
205 })
206
207 if (current.exitCode !== 0) {
208 updateBranch($, { kind: 'none', label: 'Not a Git repository' })
209 return
210 }
211
212 const name = current.stdout.trim()
213 if (name) {
214 updateBranch($, {
215 kind: name === 'main' || name === 'master' ? 'default' : 'branch',
216 label: compactBranchName(name),
217 })
218 return
219 }
220
221 const commit = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], {
222 cwd: directory,
223 timeoutMs: 5_000,
224 })
225 const shortCommit = commit.stdout.trim()
226
227 updateBranch($, commit.exitCode === 0 && shortCommit
228 ? { kind: 'detached', label: `detached@${shortCommit}` }
229 : { kind: 'none', label: 'Not a Git repository' })
230 } catch {
231 updateBranch($, { kind: 'error', label: 'Branch unavailable' })
232 } finally {
233 branchRefreshing = false
234 }
235}
236
237function friendlyName(value: string, maxLength = 28): string {
238 const readable = value.replaceAll('_', ' ')
239 if (readable.length <= maxLength) return readable
240 return `${readable.slice(0, Math.max(1, maxLength - 3))}...`
241}
242
243function toolName(tool: ContextMcpTool): string {
244 const prefix = `mcp__${tool.serverName}__`
245 const shortName = tool.name.startsWith(prefix) ? tool.name.slice(prefix.length) : tool.name
246 return friendlyName(shortName, 32)
247}
248
249function groupMcpTools(tools: readonly ContextMcpTool[]): McpServerGroup[] {
250 const groups = new Map<string, ContextMcpTool[]>()
251
252 for (const tool of tools) {
253 const group = groups.get(tool.serverName) ?? []
254 group.push(tool)
255 groups.set(tool.serverName, group)
256 }
257
258 return [...groups.entries()]
259 .map(([name, serverTools]) => ({
260 name,
261 loadedTokens: serverTools
262 .filter(tool => tool.isLoaded)
263 .reduce((total, tool) => total + tool.tokens, 0),
264 loadedTools: serverTools.filter(tool => tool.isLoaded).length,
265 deferredTools: serverTools.filter(tool => !tool.isLoaded).length,
266 tools: [...serverTools].sort((left, right) => {
267 if (left.isLoaded !== right.isLoaded) return left.isLoaded ? -1 : 1
268 return right.tokens - left.tokens || left.name.localeCompare(right.name)
269 }),
270 }))
271 .sort((left, right) => {
272 return right.loadedTokens - left.loadedTokens || left.name.localeCompare(right.name)
273 })
274}
275
276async function refreshBreakdown($: EngineInterface, columns: number): Promise<void> {
277 if (breakdownLoading) return
278
279 breakdownLoading = true
280 breakdownError = ''
281 $.ui.invalidate('ui.render')
282
283 try {
284 const usage = await $.session.usage({ breakdown: 'full', columns })
285 detailUsage = usage
286 breakdown = usage.context.breakdown ?? null
287 if (!breakdown) breakdownError = 'Claude Code did not return a context breakdown yet.'
288 } catch (error) {
289 breakdownError = error instanceof Error ? error.message : String(error)
290 } finally {
291 breakdownLoading = false
292 $.ui.invalidate('ui.render')
293 }
294}
295
296async function openBreakdown($: EngineInterface): Promise<void> {
297 selectedMcpServer = null
298 mcpPage = 0
299 await $.ui.open({
300 id: PANE,
301 title: 'Session',
302 focus: true,
303 closeOnEscape: true,
304 columns: 58,
305 })
306 await refreshBreakdown($, 58)
307}
308
309export const register: Register = (on, options) => {
310 const contextTarget = contextMaxTokens(options.context_max_tokens)
311
312 on('session.start', async ($, event, next) => {
313 branchState = { kind: 'loading', label: 'Checking branch' }
314 await refreshBranch($, event.cwd)
315
316 if (!branchRefreshStarted) {
317 branchRefreshStarted = true
318 $.clock.every(BRANCH_REFRESH_INTERVAL_MS, () => {
319 void refreshBranch($)
320 })
321 }
322
323 return next(event)
324 })
325
326 on('tool.call', { tool: 'Bash' }, async ($, event, next) => {
327 const result = await next(event)
328 await refreshBranch($)
329 return result
330 }).catch(($, event, next) => next(event))
331
332 on('session.measure', ($, event, next) => {
333 $.ui.invalidate('ui.render')
334 return next(event)
335 })
336
337 on('ui.render', { component: 'AbovePrompt' }, async ($, event, next) => {
338 if (event.props.hasSurvey) return next(event)
339
340 const { Box, Button, Text } = $.ui.resolve(event)
341 const usage = await $.session.usage()
342 const tokens = usage.context.tokens ?? 0
343 const percent = Math.max(0, Math.round((tokens / contextTarget) * 100))
344 const width = Math.max(8, Math.min(20, event.props.bodyColumns - 50))
345 const weeklyLimit = usage.rateLimits.find(limit => limit.kind === 'seven_day')
346 const meterColor = usageColor(percent)
347 const contextMeter = meter(percent, width)
348
349 return (
350 <Box flexDirection="column">
351 <Box gap={2} alignItems="center" flexWrap="wrap">
352 <Box gap={1} alignItems="center">
353 <Text dimColor>Branch</Text>
354 <Text bold color={branchColor(branchState.kind)}>{branchState.label}</Text>
355 </Box>
356 <Box gap={1} alignItems="center" flexWrap="wrap">
357 <Text bold>Context {percent}% of {tokenLabel(contextTarget)}</Text>
358 <Box>
359 <Text color={meterColor}>{contextMeter.filled}</Text>
360 <Text color="inactive">{contextMeter.remaining}</Text>
361 </Box>
362 {weeklyLimit && <Text dimColor>Week {weeklyLimit.percentUsed}%</Text>}
363 <Button
364 key="context-details"
365 label="Details"
366 plain
367 onPress={() => {
368 void openBreakdown($)
369 }}
370 />
371 </Box>
372 </Box>
373 </Box>
374 )
375 })
376
377 on('ui.render', { component: 'Pane', requestId: PANE }, ($, event) => {
378 const { Box, Button, Text } = $.ui.resolve(event)
379 const current = breakdown
380
381 if (!current) {
382 return (
383 <Box flexDirection="column" gap={1}>
384 <Box justifyContent="space-between" alignItems="center" width="100%">
385 <Text bold>Session Details</Text>
386 <Button
387 key="refresh-context"
388 label={breakdownLoading ? 'Counting' : 'Refresh'}
389 hotkey="r"
390 plain
391 dimColor
392 onPress={() => {
393 void refreshBreakdown($, event.props.bodyColumns)
394 }}
395 />
396 </Box>
397 {breakdownError ? (
398 <Text color="error">{breakdownError}</Text>
399 ) : (
400 <Text dimColor>Counting context tokens...</Text>
401 )}
402 </Box>
403 )
404 }
405
406 const usedCategories = current.categories
407 .filter(category => category.kind === 'used')
408 .sort((left, right) => right.tokens - left.tokens)
409 const freeTokens = current.categories
410 .filter(category => category.kind === 'free')
411 .reduce((total, category) => total + category.tokens, 0)
412 const bufferTokens = current.categories
413 .filter(category => category.kind === 'buffer')
414 .reduce((total, category) => total + category.tokens, 0)
415 const deferredTokens = current.categories
416 .filter(category => category.kind === 'deferred')
417 .reduce((total, category) => total + category.tokens, 0)
418 const personalPercent = Math.max(
419 0,
420 Math.round((current.totalTokens / contextTarget) * 100),
421 )
422 const windowPercent = Math.max(
423 0,
424 Math.round((current.totalTokens / current.rawMaxTokens) * 100),
425 )
426 const barWidth = Math.max(12, Math.min(34, event.props.bodyColumns - 8))
427 const shortBarWidth = Math.max(10, Math.min(22, event.props.bodyColumns - 20))
428 const progressBar = (
429 percent: number,
430 width: number,
431 color: 'success' | 'warning' | 'error' = 'success',
432 ) => {
433 const progress = meter(percent, width)
434 return (
435 <Box>
436 <Text color={color}>{progress.filled}</Text>
437 <Text color="inactive">{progress.remaining}</Text>
438 </Box>
439 )
440 }
441 const mcpServers = groupMcpTools(current.mcpTools)
442 const rateLimits = detailUsage?.rateLimits.filter(limit => (
443 limit.kind === 'five_hour' || limit.kind === 'seven_day'
444 )) ?? []
445 const mcpLoadedTokens = mcpServers.reduce((total, server) => total + server.loadedTokens, 0)
446 const selectedServer = mcpServers.find(server => server.name === selectedMcpServer)
447 const maxMcpPage = selectedServer
448 ? Math.max(0, Math.ceil(selectedServer.tools.length / MCP_TOOLS_PER_PAGE) - 1)
449 : 0
450 const visibleMcpPage = Math.min(mcpPage, maxMcpPage)
451 const visibleMcpTools = selectedServer?.tools.slice(
452 visibleMcpPage * MCP_TOOLS_PER_PAGE,
453 (visibleMcpPage + 1) * MCP_TOOLS_PER_PAGE,
454 ) ?? []
455 const serverPicker = event.surface === 'mobile' ? (
456 <Text dimColor>Tool details are available in desktop and terminal.</Text>
457 ) : (() => {
458 const { Select } = $.ui.resolve(event)
459 return (
460 <Select
461 key="mcp-server-select"
462 label="Tool details"
463 value={selectedServer?.name ?? ''}
464 options={[
465 { value: '', label: 'Choose a server' },
466 ...mcpServers.map(server => ({
467 value: server.name,
468 label: `${friendlyName(server.name, 32)} (${server.tools.length})`,
469 })),
470 ]}
471 onSelect={value => {
472 selectedMcpServer = value || null
473 mcpPage = 0
474 $.ui.invalidate('ui.render')
475 }}
476 />
477 )
478 })()
479 const contextTargetEditor = !editingContextTarget ? null : event.surface === 'mobile' ? (
480 <Text dimColor>Change Context target from /config.</Text>
481 ) : (() => {
482 const { Input } = $.ui.resolve(event)
483 return (
484 <Box flexDirection="column">
485 <Input
486 key="context-target-input"
487 label="Target"
488 value={contextTargetDraft}
489 placeholder="300k"
490 submitLabel="Save"
491 autoFocus
492 onInput={value => {
493 contextTargetDraft = value
494 contextTargetError = ''
495 }}
496 onSubmit={value => {
497 void saveContextTarget($, value)
498 }}
499 />
500 {contextTargetError && <Text color="error">{contextTargetError}</Text>}
501 </Box>
502 )
503 })()
504
505 return (
506 <Box flexDirection="column" gap={1}>
507 <Box justifyContent="space-between" alignItems="center" width="100%">
508 <Text bold>Session Details</Text>
509 <Button
510 key="refresh-context"
511 label={breakdownLoading ? 'Counting' : 'Refresh'}
512 hotkey="r"
513 plain
514 dimColor
515 onPress={() => {
516 void refreshBreakdown($, event.props.bodyColumns)
517 }}
518 />
519 </Box>
520
521 <Box flexDirection="column">
522 <Box justifyContent="space-between" width="100%">
523 <Box gap={1} alignItems="center">
524 <Text bold>{detailTokenLabel(current.totalTokens)} / {tokenLabel(contextTarget)} target</Text>
525 <Button
526 key="change-context-target"
527 label={editingContextTarget ? 'Cancel' : 'Change'}
528 plain
529 dimColor
530 onPress={() => {
531 editingContextTarget = !editingContextTarget
532 contextTargetDraft = editingContextTarget ? tokenLabel(contextTarget) : ''
533 contextTargetError = ''
534 $.ui.invalidate('ui.render')
535 }}
536 />
537 </Box>
538 <Text color={usageColor(personalPercent)} bold>{personalPercent}%</Text>
539 </Box>
540 {contextTargetEditor}
541 {progressBar(personalPercent, barWidth, usageColor(personalPercent))}
542 <Text dimColor>{current.model} · {tokenLabel(current.rawMaxTokens)} model window</Text>
543 </Box>
544
545 <Box flexDirection="column" gap={1} marginTop={1}>
546 <Text bold>Account usage</Text>
547 {rateLimits.length > 0 ? rateLimits.map(limit => (
548 <Box key={limit.kind} flexDirection="column">
549 <Box justifyContent="space-between" width="100%" gap={1}>
550 <Text>{rateLimitTitle(limit)}</Text>
551 <Text color={usageColor(limit.percentUsed)} bold>{limit.percentUsed}%</Text>
552 </Box>
553 {progressBar(limit.percentUsed, barWidth, usageColor(limit.percentUsed))}
554 {limit.resetsAt && <Text dimColor>{resetLabel(limit.resetsAt)}</Text>}
555 </Box>
556 )) : (
557 <Text dimColor>Rate-limit usage is not available yet.</Text>
558 )}
559 {detailUsage?.cost && (
560 <Box justifyContent="space-between" width="100%">
561 <Text>Session cost</Text>
562 <Text dimColor>${detailUsage.cost.usd.toFixed(2)}</Text>
563 </Box>
564 )}
565 </Box>
566
567 <Box flexDirection="column" gap={1}>
568 <Text bold>What is using context</Text>
569 {usedCategories.map(category => {
570 const share = current.totalTokens > 0
571 ? (category.tokens / current.totalTokens) * 100
572 : 0
573
574 return (
575 <Box key={`${category.kind}:${category.name}`} flexDirection="column">
576 <Box justifyContent="space-between" width="100%" gap={1}>
577 <Text wrap="truncate">{category.name}</Text>
578 <Text dimColor>{detailTokenLabel(category.tokens)} · {percentage(category.tokens, current.totalTokens)}</Text>
579 </Box>
580 {progressBar(share, barWidth)}
581 </Box>
582 )
583 })}
584 </Box>
585
586 <Box flexDirection="column" marginTop={1}>
587 <Box justifyContent="space-between" width="100%">
588 <Text bold>Model window</Text>
589 <Text dimColor>{detailTokenLabel(current.totalTokens)} / {tokenLabel(current.rawMaxTokens)}</Text>
590 </Box>
591 {progressBar(windowPercent, barWidth, usageColor(windowPercent))}
592 <Box gap={2} flexWrap="wrap">
593 {freeTokens > 0 && <Text dimColor>Free {detailTokenLabel(freeTokens)}</Text>}
594 {bufferTokens > 0 && <Text dimColor>Buffer {detailTokenLabel(bufferTokens)}</Text>}
595 {deferredTokens > 0 && <Text dimColor>Deferred {detailTokenLabel(deferredTokens)}</Text>}
596 </Box>
597 </Box>
598
599 {mcpServers.length > 0 && (
600 <Box flexDirection="column" gap={1} marginTop={1}>
601 <Box justifyContent="space-between" width="100%">
602 <Text bold>MCP servers</Text>
603 <Text dimColor>{detailTokenLabel(mcpLoadedTokens)} loaded</Text>
604 </Box>
605
606 {mcpServers.slice(0, MCP_SERVER_LIMIT).map(server => {
607 const share = mcpLoadedTokens > 0
608 ? (server.loadedTokens / mcpLoadedTokens) * 100
609 : 0
610
611 return (
612 <Box key={`mcp-server-${server.name}`} flexDirection="column">
613 <Box justifyContent="space-between" width="100%" gap={1}>
614 <Text wrap="truncate">{friendlyName(server.name)}</Text>
615 <Text dimColor>
616 {detailTokenLabel(server.loadedTokens)} · {server.loadedTools}/{server.tools.length} tools
617 </Text>
618 </Box>
619 {progressBar(share, barWidth)}
620 </Box>
621 )
622 })}
623
624 {mcpServers.length > MCP_SERVER_LIMIT && (
625 <Text dimColor>{mcpServers.length - MCP_SERVER_LIMIT} more servers in the picker</Text>
626 )}
627
628 {serverPicker}
629
630 {selectedServer && (
631 <Box flexDirection="column" gap={1}>
632 <Box justifyContent="space-between" alignItems="center" width="100%">
633 <Text bold>{friendlyName(selectedServer.name)} tools</Text>
634 <Box gap={1}>
635 <Text dimColor>
636 {visibleMcpPage * MCP_TOOLS_PER_PAGE + 1}-{Math.min((visibleMcpPage + 1) * MCP_TOOLS_PER_PAGE, selectedServer.tools.length)} of {selectedServer.tools.length}
637 </Text>
638 {visibleMcpPage > 0 && (
639 <Button
640 key="mcp-previous"
641 label="Previous"
642 plain
643 onPress={() => {
644 mcpPage = Math.max(0, visibleMcpPage - 1)
645 $.ui.invalidate('ui.render')
646 }}
647 />
648 )}
649 {visibleMcpPage < maxMcpPage && (
650 <Button
651 key="mcp-next"
652 label="Next"
653 plain
654 onPress={() => {
655 mcpPage = Math.min(maxMcpPage, visibleMcpPage + 1)
656 $.ui.invalidate('ui.render')
657 }}
658 />
659 )}
660 </Box>
661 </Box>
662
663 {visibleMcpTools.map(tool => (
664 <Box key={`${tool.serverName}:${tool.name}`} flexDirection="column">
665 <Box justifyContent="space-between" width="100%" gap={1}>
666 <Text color={tool.isLoaded ? 'success' : 'inactive'} wrap="truncate">
667 {toolName(tool)}
668 </Text>
669 <Text dimColor>
670 {tool.isLoaded ? detailTokenLabel(tool.tokens) : 'deferred'}
671 </Text>
672 </Box>
673 {tool.isLoaded && (
674 progressBar(selectedServer.loadedTokens > 0
675 ? (tool.tokens / selectedServer.loadedTokens) * 100
676 : 0, shortBarWidth)
677 )}
678 </Box>
679 ))}
680
681 {selectedServer.deferredTools > 0 && (
682 <Text dimColor>
683 {selectedServer.deferredTools} deferred schemas are outside active context.
684 </Text>
685 )}
686 </Box>
687 )}
688 </Box>
689 )}
690
691 <Text dimColor>
692 Messages is the conversation total. Per-message counts are not available.
693 </Text>
694 {breakdownError && <Text color="error">Last refresh failed: {breakdownError}</Text>}
695 </Box>
696 )
697 })
698}
699hooks/config.ts 4 lines1export const DEFAULT_CONTEXT_MAX_TOKENS = 300_000
2export const MIN_CONTEXT_MAX_TOKENS = 10_000
3export const MAX_CONTEXT_MAX_TOKENS = 10_000_000
4