SLOPSHOPPER

session-hud

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

newpanebandguardprocesstimer
★ 2v0.4.0MITupdated 2026-10-15bradtraversy/claude-mods/session-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hud
│ ┃ context-breakdown ✕ › fix the failing auth test and add an audit log call │ ┃ Session Details r: Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ Counting context tokens... ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ Branch feat/auth-refresh Context 32% of 300k ██████░░░░░░░░░░░░░░ Details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Branch feat/auth-refresh Context 32% of 300k ██████░░░░░░░░░░░░░░ Details
Pane · context-breakdown
Session Details r: Refresh Counting context tokens...
README

Claude Code Mods

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.

What is a mod?

A mod is a Claude Code plugin with JavaScript or TypeScript event handlers. Claude Code calls those handlers when something happens, such as:

  • A session starts
  • Claude calls a tool
  • A command is submitted
  • Part of the interface is rendered
  • Context usage changes

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:

  • Claude Code in a terminal
  • Local sessions in the Code tab of the Claude Desktop app
  • SSH sessions in the Code tab when plugins are available on the remote host

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.

Requirements

  • Claude Code 2.1.287 or newer for terminal use
  • A desktop app version that includes Claude Code 2.1.286 or newer for the Code tab
  • Git, so Claude Code can clone this marketplace
  • GitHub CLI, authenticated with gh auth login, only if you want Verification Ledger to display remote GitHub Actions runs

Check your terminal version:

claude --version

In the desktop Code tab, enter /status and check the Claude Code version.

Quick start

1. Add the marketplace

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

2. Install the mods you want

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.

3. Load the mods

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.

Use the same mods in terminal and desktop

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:

  1. Open the Code tab.
  2. Start a local session.
  3. Enter /reload-plugins, or start a new session after installation.
  4. Enter /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.

Included mods

ModWhat it doesHow to open or test it
Session HUDShows the current Git branch, context, and weekly usage with detailed session informationSwitch branches or click Details beside the context meter
Impact MapTracks touched files and opens current Git diffsSelect an edited or created file in the Impact Map
Tool CardsReplaces normal tool-use rows with compact status cardsAsk Claude to read a file or run a command
Safe ShellDemonstrates a confirmation gate for a marked Bash commandAsk Claude to run echo __SAFE_SHELL_DEMO__
Quality ConsoleValidates a local mod from inside Claude CodeEnter /mod-check <path>
Verification LedgerTracks local checks, Git state, and GitHub ActionsEnter /checks
Agent PulseShows live subagent status, activity, elapsed time, and recent transcriptsAsk Claude to use a subagent or enter /agents
Work QueueGroups session tasks by working, blocked, ready, and doneAsk Claude to create tasks or enter /work-queue

Session HUD

Session HUD draws a compact band above the prompt with:

  • The current Git branch
  • Current context usage
  • A visual progress bar
  • Overall weekly account usage

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:

  • Five-hour account usage with its reset time
  • Weekly account usage with its reset time
  • Session cost when Claude Code reports one
  • A color-coded meter for your personal context target

Claude Code also counts the current context and shows:

  • Clean progress bars for messages, system content, tool schemas, memory, skills, and agents
  • Messages as one conversation total
  • Loaded MCP tokens grouped by server, with tool details available from a server picker
  • Free space and the automatic compaction buffer

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.

Change the context target

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.

Test Session HUD

  1. Install and load session-hud.
  2. Send a prompt so Claude Code records session usage.
  3. Look directly above the prompt box.
  4. Confirm that the current branch, context, and weekly usage appear.
  5. Switch branches in the built-in terminal and confirm that the label updates within five seconds.
  6. Confirm that the Week label shows your weekly percentage.
  7. Click Details beside the progress bar.
  8. Confirm that Session Details shows five-hour usage, weekly usage, session cost, context categories, and any connected MCP tool schemas.
  9. Select Change, enter another context target, and confirm that the percentage updates after the mod reloads.

Source: session-hud/hooks/register.tsx

Impact Map

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 tool
  • EDITED when Claude uses the Edit tool
  • CREATED when Claude uses the Write tool

The 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.

Test Impact Map

  1. Install and load impact-map.
  2. Ask Claude to read two files and edit one of them.
  3. Watch the Impact Map pane update.
  4. Select the edited file and confirm that its highlighted diff opens in a new pane tab.
  5. If the Impact Map pane is closed, enter /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

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:

  • RUNNING
  • DONE
  • FAILED
  • STOPPED

For 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.

Test Tool Cards

  1. Install and load tool-cards.
  2. Ask Claude to read a file.
  3. Watch the card change from RUNNING to DONE.
  4. Ask Claude to run a command that fails if you want to see the 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

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.

Test Safe Shell

  1. Install and load safe-shell.
  2. Ask Claude to run this exact command:
   echo __SAFE_SHELL_DEMO__
  1. Claude Code should show a confirmation prompt from Safe Shell before the command runs.
  2. Choose whether to continue or cancel.
  3. Run a normal command such as git status and confirm that Safe Shell does not interfere.

Source: safe-shell/hooks/register.ts

Quality Console

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.

Test Quality Console

  1. Install and load quality-console.
  2. Clone this repository if you do not already have a local mod to check.
  3. Enter /mod-check with the absolute path to one of the mod directories.
  4. Confirm that the result starts with PASS.

Example:

/mod-check /path/to/claude-mods/session-hud

Source: quality-console/hooks/register.ts

Verification Ledger

Verification Ledger opens a Checks pane that combines three kinds of evidence:

  • Local verification commands run by Claude through the Bash tool
  • Current Git branch, commit, working-tree state, and upstream state
  • GitHub Actions runs for the current commit

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.

Recognized local commands

The ledger recognizes common commands in these categories:

  • Verify: npm run verify, pnpm verify, yarn verify, bun verify, and ./scripts/verify
  • Tests: package test scripts, Vitest, Jest, Pytest, cargo test, and go test
  • Lint: package lint scripts, ESLint, Biome, Ruff, golangci-lint, and Cargo Clippy
  • Typecheck: package typecheck scripts, tsc --noEmit, Mypy, and Pyright
  • Build: package build scripts, cargo build, and go build

The 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.

Pass, fail, and stale results

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.

GitHub Actions support

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.

Change the polling interval

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.

Test Verification Ledger

  1. Install and load verification-ledger in a Git repository.
  2. Enter /checks.
  3. Ask Claude to run the project's test command.
  4. Confirm that Tests appears with PASS or FAIL.
  5. Ask Claude to edit a file.
  6. Reopen or refresh /checks and confirm that the earlier result is STALE.
  7. Push a commit with GitHub Actions configured.
  8. Leave the pane open and watch the workflow move from RUNNING to its final state.

Source: verification-ledger/hooks/register.tsx

Agent Pulse

Agent Pulse opens an Agents pane when the first subagent starts in the current session. It gives each agent a compact card with:

  • Current status
  • Agent name or task description
  • Elapsed time since the mod first observed it
  • Current tool while one is running
  • Most recent tool after it finishes

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 begins
  • RUNNING while the agent is working
  • WAITING when it is waiting on another action
  • IDLE when a teammate is available for more work
  • COMPLETED after a successful result
  • FAILED after an error or refusal
  • KILLED after an interrupted run

Agent 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.

Test Agent Pulse

  1. Install and load agent-pulse.
  2. Enter /agents and confirm that the empty state says there are no subagents in the session.
  3. Ask Claude to use an Explore subagent to inspect a small part of the current project.
  4. Confirm that the Agents pane opens without taking focus.
  5. Watch the card update with the agent status, elapsed time, and current or last tool.
  6. Select View and confirm that the recent prompt, response, and tool names appear.
  7. Select Back to return to the agent list.
  8. Repeat the same check in a terminal session and a local session in the desktop Code tab.

Source: agent-pulse/hooks/register.tsx

Work Queue

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 progress
  • Blocked for pending tasks with unfinished dependencies
  • Ready for pending tasks that can start
  • Done for completed tasks

Each 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.

Test Work Queue

  1. Install and load work-queue.
  2. Enter /work-queue and confirm that the empty state says there are no tasks in the session.
  3. Ask Claude to create a three-step task plan where the interface task depends on the API task.
  4. Ask Claude to start the API task.
  5. Confirm that the pane shows one task under Working, the dependent task under Blocked, and the remaining task under Ready.
  6. Select View on the blocked task and confirm that the API task appears under Blocked by.
  7. Complete the API task and confirm that it moves to Done while the dependent task moves to Ready.
  8. Repeat the check in a terminal session and a local session in the desktop Code tab.

Source: work-queue/hooks/register.tsx

How the mods work together

The mods have separate responsibilities, so their interface elements do not duplicate each other:

  • Session HUD owns the band above the prompt.
  • Tool Cards owns tool-use rows in the transcript.
  • Impact Map owns a file-activity pane.
  • Verification Ledger owns a checks pane.
  • Agent Pulse owns a live subagent pane.
  • Work Queue owns a task-state pane.
  • Quality Console owns /mod-check.
  • Safe Shell owns only its marked-command confirmation.

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.

Develop locally from scratch

You do not need Node.js, a bundler, or a dependency installation. Claude Code loads TypeScript mod files directly and supplies the test runner.

1. Clone the repository

git clone https://github.com/bradtraversy/claude-mods.git
cd claude-mods

2. Validate the marketplace and every mod

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.

3. Run the automated tests

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.

4. Load one mod for a development session

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

5. Use the local checkout in terminal and desktop

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.

Anatomy of each mod

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.json

The plugin manifest provides the mod name, version, description, and author.

hooks.json

This file points Claude Code to the TypeScript module that registers the event handlers.

register.ts or register.tsx

The 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

Source 2 files
hooks/register.tsx 699 lines
1import 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}
699
hooks/config.ts 4 lines
1export 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