Shows branch safety, context headroom, and account usage in a compact session telemetry strip

Session HUD draws a compact telemetry strip above the prompt with the current Git branch, context usage, a visual progress bar, and overall weekly account usage.

main, master, or a detached HEADAdd the marketplace once, then install the mod:
claude plugin marketplace add bradtraversy/traversy-claude-mods
claude plugin install session-hud@traversy-mods
Restart the session or enter /reload-plugins after installation. Run /plugin to confirm it is enabled.
The branch signal and name appear first. Working branches use green. main and master use yellow as a reminder that you are on the default branch. Detached HEAD states are also yellow, and folders outside a Git repository display none. 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 context meter carries the strongest visual weight, followed by the compact Week label for overall seven-day account usage. Select Details to open Session Details. Its context-budget summary shows how much context is used, the percentage of the personal target, and how much headroom remains. The pane also shows:
<img src="../docs/images/session-hud-details.png" alt="Session Details showing context headroom, cost, and context makeup" width="360">
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.
In the terminal, Session Details uses a narrower compact layout that keeps both account-usage windows visible sooner. The Details control hides while that pane is open. The Desktop layout keeps its roomier spacing.
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.Details control appear.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.claude plugin uninstall session-hud@traversy-mods
hooks/register.tsx 806 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 = ''
31let breakdownGeneration = 0
32
33type BranchState = {
34 kind: 'loading' | 'branch' | 'default' | 'detached' | 'none' | 'error'
35 label: string
36}
37
38let branchState: BranchState = {
39 kind: 'loading',
40 label: 'Checking branch',
41}
42
43type McpServerGroup = {
44 name: string
45 loadedTokens: number
46 loadedTools: number
47 deferredTools: number
48 tools: ContextMcpTool[]
49}
50
51function clearSessionDetails(): void {
52 breakdownGeneration += 1
53 breakdown = null
54 breakdownError = ''
55 breakdownLoading = false
56 detailUsage = null
57 selectedMcpServer = null
58 mcpPage = 0
59 editingContextTarget = false
60 contextTargetDraft = ''
61 contextTargetError = ''
62}
63
64function meter(percent: number, width: number): { filled: string; remaining: string } {
65 const safePercent = Math.min(Math.max(percent, 0), 100)
66 const rounded = Math.round((safePercent / 100) * width)
67 const filled = safePercent > 0 ? Math.max(1, rounded) : 0
68 return {
69 filled: '█'.repeat(filled),
70 remaining: '░'.repeat(width - filled),
71 }
72}
73
74function rateLimitLabel(kind: string): string {
75 if (kind === 'five_hour') return '5h'
76 if (kind === 'seven_day') return '7d'
77 return kind.replaceAll('_', ' ')
78}
79
80function rateLimitTitle(limit: SessionRateLimit): string {
81 if (limit.kind === 'five_hour') return 'Five-hour usage'
82 if (limit.kind === 'seven_day') return 'Weekly usage'
83 return rateLimitLabel(limit.kind)
84}
85
86function resetLabel(resetsAt?: string): string {
87 if (!resetsAt) return ''
88
89 const date = new Date(resetsAt)
90 if (Number.isNaN(date.getTime())) return ''
91
92 return `Resets ${date.toLocaleString([], {
93 weekday: 'short',
94 hour: 'numeric',
95 minute: '2-digit',
96 })}`
97}
98
99function tokenLabel(tokens: number): string {
100 if (tokens >= 1_000_000) {
101 const millions = tokens / 1_000_000
102 return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}m`
103 }
104
105 return `${Math.round(tokens / 1_000)}k`
106}
107
108function modelWindowLabel(tokens: number): string {
109 return tokenLabel(tokens).replace(/m$/, 'M')
110}
111
112function detailTokenLabel(tokens: number): string {
113 if (tokens >= 1_000_000) {
114 const millions = tokens / 1_000_000
115 return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}m`
116 }
117
118 if (tokens >= 10_000) return `${Math.round(tokens / 1_000)}k`
119 if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`
120 return tokens.toLocaleString()
121}
122
123function targetHeadroomLabel(tokens: number, target: number): string {
124 const remaining = target - tokens
125 if (remaining === 0) return 'At target'
126 if (remaining > 0) return `${detailTokenLabel(remaining)} until target`
127 return `${detailTokenLabel(Math.abs(remaining))} over target`
128}
129
130function contextMaxTokens(value: unknown): number {
131 if (typeof value !== 'number' || !Number.isFinite(value)) {
132 return DEFAULT_CONTEXT_MAX_TOKENS
133 }
134
135 return Math.min(
136 MAX_CONTEXT_MAX_TOKENS,
137 Math.max(MIN_CONTEXT_MAX_TOKENS, Math.round(value)),
138 )
139}
140
141function parseContextTarget(answer: string): number | null {
142 const normalized = answer.trim().toLowerCase().replaceAll(',', '').replaceAll('_', '')
143 const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([km])?$/)
144 if (!match) return null
145
146 const amount = Number(match[1])
147 const multiplier = match[2] === 'm' ? 1_000_000 : match[2] === 'k' ? 1_000 : 1
148 const tokens = Math.round(amount * multiplier)
149
150 if (tokens < MIN_CONTEXT_MAX_TOKENS || tokens > MAX_CONTEXT_MAX_TOKENS) return null
151 return tokens
152}
153
154async function saveContextTarget($: EngineInterface, answer: string): Promise<void> {
155 contextTargetDraft = answer
156 contextTargetError = ''
157
158 try {
159 const target = parseContextTarget(answer)
160
161 if (!target) {
162 contextTargetError = 'Enter 10k to 10m, such as 300k or 1m.'
163 $.ui.invalidate('ui.render')
164 return
165 }
166
167 const result = await $.config.set({
168 key: 'session-hud.context_max_tokens',
169 value: target,
170 })
171
172 if ('deny' in result && result.deny) {
173 contextTargetError = result.deny
174 $.ui.invalidate('ui.render')
175 return
176 }
177
178 editingContextTarget = false
179 contextTargetError = ''
180 $.ui.invalidate('ui.render')
181 } catch {
182 contextTargetError = 'The context target could not be saved.'
183 $.ui.invalidate('ui.render')
184 }
185}
186
187function percentage(tokens: number, total: number): string {
188 if (total <= 0) return '0%'
189 const value = (tokens / total) * 100
190 return `${value >= 10 ? Math.round(value) : value.toFixed(1)}%`
191}
192
193function usageColor(percent: number): 'success' | 'warning' | 'error' {
194 if (percent >= 90) return 'error'
195 if (percent >= 70) return 'warning'
196 return 'success'
197}
198
199function branchColor(kind: BranchState['kind']): 'success' | 'warning' | 'inactive' {
200 if (kind === 'branch') return 'success'
201 if (kind === 'default' || kind === 'detached') return 'warning'
202 return 'inactive'
203}
204
205function compactBranchName(name: string, maxLength = 36): string {
206 if (name.length <= maxLength) return name
207
208 const tailLength = 10
209 const headLength = maxLength - tailLength - 3
210 return `${name.slice(0, headLength)}...${name.slice(-tailLength)}`
211}
212
213function updateBranch($: EngineInterface, nextState: BranchState): void {
214 if (branchState.kind === nextState.kind && branchState.label === nextState.label) return
215
216 branchState = nextState
217 $.ui.invalidate('ui.render')
218}
219
220async function refreshBranch($: EngineInterface, cwd?: string): Promise<void> {
221 if (branchRefreshing) return
222
223 branchRefreshing = true
224
225 try {
226 const directory = cwd ?? await $.session.cwd()
227 const current = await $.process.run(['git', 'branch', '--show-current'], {
228 cwd: directory,
229 timeoutMs: 5_000,
230 })
231
232 if (current.exitCode !== 0) {
233 updateBranch($, { kind: 'none', label: 'none' })
234 return
235 }
236
237 const name = current.stdout.trim()
238 if (name) {
239 updateBranch($, {
240 kind: name === 'main' || name === 'master' ? 'default' : 'branch',
241 label: compactBranchName(name),
242 })
243 return
244 }
245
246 const commit = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], {
247 cwd: directory,
248 timeoutMs: 5_000,
249 })
250 const shortCommit = commit.stdout.trim()
251
252 updateBranch($, commit.exitCode === 0 && shortCommit
253 ? { kind: 'detached', label: `detached@${shortCommit}` }
254 : { kind: 'none', label: 'none' })
255 } catch {
256 updateBranch($, { kind: 'error', label: 'Branch unavailable' })
257 } finally {
258 branchRefreshing = false
259 }
260}
261
262function friendlyName(value: string, maxLength = 28): string {
263 const readable = value.replaceAll('_', ' ')
264 if (readable.length <= maxLength) return readable
265 return `${readable.slice(0, Math.max(1, maxLength - 3))}...`
266}
267
268function toolName(tool: ContextMcpTool): string {
269 const prefix = `mcp__${tool.serverName}__`
270 const shortName = tool.name.startsWith(prefix) ? tool.name.slice(prefix.length) : tool.name
271 return friendlyName(shortName, 32)
272}
273
274function groupMcpTools(tools: readonly ContextMcpTool[]): McpServerGroup[] {
275 const groups = new Map<string, ContextMcpTool[]>()
276
277 for (const tool of tools) {
278 const group = groups.get(tool.serverName) ?? []
279 group.push(tool)
280 groups.set(tool.serverName, group)
281 }
282
283 return [...groups.entries()]
284 .map(([name, serverTools]) => ({
285 name,
286 loadedTokens: serverTools
287 .filter(tool => tool.isLoaded)
288 .reduce((total, tool) => total + tool.tokens, 0),
289 loadedTools: serverTools.filter(tool => tool.isLoaded).length,
290 deferredTools: serverTools.filter(tool => !tool.isLoaded).length,
291 tools: [...serverTools].sort((left, right) => {
292 if (left.isLoaded !== right.isLoaded) return left.isLoaded ? -1 : 1
293 return right.tokens - left.tokens || left.name.localeCompare(right.name)
294 }),
295 }))
296 .sort((left, right) => {
297 return right.loadedTokens - left.loadedTokens || left.name.localeCompare(right.name)
298 })
299}
300
301async function refreshBreakdown(
302 $: EngineInterface,
303 columns: number,
304 generation = breakdownGeneration,
305): Promise<void> {
306 if (breakdownLoading) return
307 if (generation !== breakdownGeneration) return
308
309 breakdownLoading = true
310 breakdownError = ''
311 $.ui.invalidate('ui.render')
312
313 try {
314 const usage = await $.session.usage({ breakdown: 'full', columns })
315 if (generation !== breakdownGeneration) return
316 detailUsage = usage
317 breakdown = usage.context.breakdown ?? null
318 if (!breakdown) breakdownError = 'Claude Code did not return a context breakdown yet.'
319 } catch (error) {
320 if (generation !== breakdownGeneration) return
321 breakdownError = error instanceof Error ? error.message : String(error)
322 } finally {
323 if (generation === breakdownGeneration) {
324 breakdownLoading = false
325 $.ui.invalidate('ui.render')
326 }
327 }
328}
329
330async function isBreakdownOpen($: EngineInterface): Promise<boolean> {
331 try {
332 const pane = (await $.ui.panes()).find(item => item.id === PANE)
333 return Boolean(pane?.isPlaced && pane.isShown)
334 } catch {
335 return false
336 }
337}
338
339async function openBreakdown($: EngineInterface, surface: string): Promise<void> {
340 const generation = breakdownGeneration
341 const columns = surface === 'terminal' ? 52 : 58
342 selectedMcpServer = null
343 mcpPage = 0
344 await $.ui.open({
345 id: PANE,
346 title: 'Session',
347 focus: true,
348 closeOnEscape: true,
349 columns,
350 })
351 await refreshBreakdown($, columns, generation)
352}
353
354export const register: Register = (on, options) => {
355 const contextTarget = contextMaxTokens(options.context_max_tokens)
356
357 on('session.start', async ($, event, next) => {
358 clearSessionDetails()
359 branchState = { kind: 'loading', label: 'Checking branch' }
360 await refreshBranch($, event.cwd)
361
362 if (!branchRefreshStarted) {
363 branchRefreshStarted = true
364 $.clock.every(BRANCH_REFRESH_INTERVAL_MS, () => {
365 void refreshBranch($)
366 })
367 }
368
369 return next(event)
370 })
371
372 on('session.end', ($, event, next) => {
373 if (event.reason === 'clear' || event.reason === 'resume') {
374 clearSessionDetails()
375 $.ui.invalidate('ui.render')
376 }
377 return next(event)
378 })
379
380 on('tool.call', { tool: 'Bash' }, async ($, event, next) => {
381 const result = await next(event)
382 await refreshBranch($)
383 return result
384 }).catch(($, event, next) => next(event))
385
386 on('session.measure', ($, event, next) => {
387 $.ui.invalidate('ui.render')
388 return next(event)
389 })
390
391 on('ui.render', { component: 'AbovePrompt' }, async ($, event, next) => {
392 if (event.props.hasSurvey) return next(event)
393
394 // Bands other mods draw beneath this hook only show if passed on.
395 const rest = await next(event)
396 const { Box, Button, Text } = $.ui.resolve(event)
397 const usage = await $.session.usage()
398 const tokens = usage.context.tokens ?? 0
399 const percent = Math.max(0, Math.round((tokens / contextTarget) * 100))
400 // The desktop draws block glyphs wider than a text cell, so its meter is shorter.
401 const maxMeter = event.surface === 'terminal' ? 18 : 10
402 const width = Math.max(6, Math.min(maxMeter, event.props.bodyColumns - 60))
403 const weeklyLimit = usage.rateLimits.find(limit => limit.kind === 'seven_day')
404 const meterColor = usageColor(percent)
405 const contextMeter = meter(percent, width)
406 const detailsOpen = event.surface === 'terminal' && await isBreakdownOpen($)
407
408 return (
409 <Box flexDirection="column">
410 {rest}
411 <Box gap={2} alignItems="center" flexWrap="wrap" overflow="hidden">
412 <Box gap={1} alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
413 <Text color={branchColor(branchState.kind)} bold>●</Text>
414 <Text bold color={branchColor(branchState.kind)} wrap="truncate-middle">
415 {branchState.label}
416 </Text>
417 </Box>
418 <Box gap={1} alignItems="center" flexShrink={0}>
419 <Text bold>Context</Text>
420 <Box flexShrink={1} overflow="hidden">
421 <Text color={meterColor}>{contextMeter.filled}</Text>
422 <Text color="inactive">{contextMeter.remaining}</Text>
423 </Box>
424 <Text color={meterColor} bold>{percent}% of {tokenLabel(contextTarget)}</Text>
425 </Box>
426 {weeklyLimit && (
427 <Box flexShrink={0}>
428 <Text dimColor>Week {weeklyLimit.percentUsed}%</Text>
429 </Box>
430 )}
431 {!detailsOpen && (
432 <Button
433 key="context-details"
434 label="Details"
435 variant="secondary"
436 onPress={() => {
437 void openBreakdown($, event.surface)
438 }}
439 />
440 )}
441 </Box>
442 </Box>
443 )
444 })
445
446 on('ui.render', { component: 'Pane', requestId: PANE }, ($, event) => {
447 const { Box, Button, Text } = $.ui.resolve(event)
448 const current = breakdown
449 const isTerminal = event.surface === 'terminal'
450
451 if (!current) {
452 return (
453 <Box flexDirection="column" gap={1}>
454 <Box justifyContent="space-between" alignItems="center" gap={1} overflow="hidden">
455 <Text bold wrap="truncate-end">Session Details</Text>
456 <Button
457 key="refresh-context"
458 label={breakdownLoading ? 'Counting' : 'Refresh'}
459 hotkey="r"
460 variant="secondary"
461 onPress={() => {
462 void refreshBreakdown($, event.props.bodyColumns)
463 }}
464 />
465 </Box>
466 {breakdownError ? (
467 <Text color="error">{breakdownError}</Text>
468 ) : (
469 <Text dimColor>Counting context tokens...</Text>
470 )}
471 </Box>
472 )
473 }
474
475 const usedCategories = current.categories
476 .filter(category => category.kind === 'used')
477 .sort((left, right) => right.tokens - left.tokens)
478 const freeTokens = current.categories
479 .filter(category => category.kind === 'free')
480 .reduce((total, category) => total + category.tokens, 0)
481 const bufferTokens = current.categories
482 .filter(category => category.kind === 'buffer')
483 .reduce((total, category) => total + category.tokens, 0)
484 const deferredTokens = current.categories
485 .filter(category => category.kind === 'deferred')
486 .reduce((total, category) => total + category.tokens, 0)
487 const personalPercent = Math.max(
488 0,
489 Math.round((current.totalTokens / contextTarget) * 100),
490 )
491 const windowPercent = Math.max(
492 0,
493 Math.round((current.totalTokens / current.rawMaxTokens) * 100),
494 )
495 const barWidth = Math.max(12, Math.min(isTerminal ? 28 : 34, event.props.bodyColumns - 8))
496 const shortBarWidth = Math.max(10, Math.min(22, event.props.bodyColumns - 20))
497 const progressBar = (
498 percent: number,
499 width: number,
500 color: 'success' | 'warning' | 'error' = 'success',
501 ) => {
502 if (!isTerminal) {
503 // Graphical surfaces draw block glyphs wider than a cell, so a glyph bar wraps there.
504 const filled = Number.isFinite(percent) ? Math.round(Math.min(Math.max(percent, 0), 100)) : 0
505 return (
506 <Box width="100%" height={1} backgroundColor="subtle" overflow="hidden">
507 {filled > 0 && <Box width={`${Math.max(filled, 1)}%`} height={1} backgroundColor={color} />}
508 </Box>
509 )
510 }
511 const progress = meter(percent, width)
512 return (
513 <Box>
514 <Text color={color}>{progress.filled}</Text>
515 <Text color="inactive">{progress.remaining}</Text>
516 </Box>
517 )
518 }
519 const mcpServers = groupMcpTools(current.mcpTools)
520 const rateLimits = detailUsage?.rateLimits.filter(limit => (
521 limit.kind === 'five_hour' || limit.kind === 'seven_day'
522 )) ?? []
523 const mcpLoadedTokens = mcpServers.reduce((total, server) => total + server.loadedTokens, 0)
524 const selectedServer = mcpServers.find(server => server.name === selectedMcpServer)
525 const maxMcpPage = selectedServer
526 ? Math.max(0, Math.ceil(selectedServer.tools.length / MCP_TOOLS_PER_PAGE) - 1)
527 : 0
528 const visibleMcpPage = Math.min(mcpPage, maxMcpPage)
529 const visibleMcpTools = selectedServer?.tools.slice(
530 visibleMcpPage * MCP_TOOLS_PER_PAGE,
531 (visibleMcpPage + 1) * MCP_TOOLS_PER_PAGE,
532 ) ?? []
533 const serverPicker = event.surface === 'mobile' ? (
534 <Text dimColor>Tool details are available in desktop and terminal.</Text>
535 ) : (() => {
536 const { Select } = $.ui.resolve(event)
537 return (
538 <Select
539 key="mcp-server-select"
540 label="Tool details"
541 value={selectedServer?.name ?? ''}
542 options={[
543 { value: '', label: 'Choose a server' },
544 ...mcpServers.map(server => ({
545 value: server.name,
546 label: `${friendlyName(server.name, 32)} (${server.tools.length})`,
547 })),
548 ]}
549 onSelect={value => {
550 selectedMcpServer = value || null
551 mcpPage = 0
552 $.ui.invalidate('ui.render')
553 }}
554 />
555 )
556 })()
557 const contextTargetEditor = !editingContextTarget ? null : event.surface === 'mobile' ? (
558 <Text dimColor>Change Context target from /config.</Text>
559 ) : (() => {
560 const { Input } = $.ui.resolve(event)
561 return (
562 <Box flexDirection="column">
563 <Input
564 key="context-target-input"
565 label="Target"
566 value={contextTargetDraft}
567 placeholder="300k"
568 submitLabel="Save"
569 autoFocus
570 onInput={value => {
571 contextTargetDraft = value
572 contextTargetError = ''
573 }}
574 onSubmit={value => {
575 void saveContextTarget($, value)
576 }}
577 />
578 {contextTargetError && <Text color="error">{contextTargetError}</Text>}
579 </Box>
580 )
581 })()
582
583 return (
584 <Box key="session-details" flexDirection="column" gap={isTerminal ? 0 : 1}>
585 <Box justifyContent="space-between" alignItems="center" gap={1} overflow="hidden">
586 <Text bold wrap="truncate-end">Session Details</Text>
587 <Button
588 key="refresh-context"
589 label={breakdownLoading ? 'Counting' : 'Refresh'}
590 hotkey="r"
591 variant="secondary"
592 onPress={() => {
593 void refreshBreakdown($, event.props.bodyColumns)
594 }}
595 />
596 </Box>
597
598 <Box
599 key="context-summary"
600 flexDirection="column"
601 gap={isTerminal ? 0 : 1}
602 borderStyle="round"
603 borderColor={usageColor(personalPercent)}
604 paddingX={1}
605 overflow="hidden"
606 >
607 <Box justifyContent="space-between" alignItems="center" gap={1} overflow="hidden">
608 <Box gap={1} flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
609 <Text color={usageColor(personalPercent)} bold>●</Text>
610 <Text bold wrap="truncate-end">
611 {detailTokenLabel(current.totalTokens)} used
612 </Text>
613 </Box>
614 <Text color={usageColor(personalPercent)} bold>{personalPercent}%</Text>
615 </Box>
616 {progressBar(personalPercent, barWidth, usageColor(personalPercent))}
617 <Box
618 key="context-target-row"
619 justifyContent="space-between"
620 alignItems="center"
621 gap={1}
622 overflow="hidden"
623 >
624 <Text dimColor wrap="truncate-end">
625 {targetHeadroomLabel(current.totalTokens, contextTarget)}
626 </Text>
627 <Box flexShrink={0}>
628 <Button
629 key="change-context-target"
630 label={editingContextTarget ? 'Cancel' : 'Change'}
631 variant="secondary"
632 onPress={() => {
633 editingContextTarget = !editingContextTarget
634 contextTargetDraft = editingContextTarget ? tokenLabel(contextTarget) : ''
635 contextTargetError = ''
636 $.ui.invalidate('ui.render')
637 }}
638 />
639 </Box>
640 </Box>
641 {contextTargetEditor}
642 <Text dimColor wrap="truncate-end">
643 {current.model} · {modelWindowLabel(current.rawMaxTokens)} model window
644 </Text>
645 </Box>
646
647 <Box
648 key="account-usage"
649 flexDirection="column"
650 gap={isTerminal ? 0 : 1}
651 marginTop={isTerminal ? 0 : 1}
652 >
653 <Text bold>Account usage</Text>
654 {rateLimits.length > 0 ? rateLimits.map(limit => (
655 <Box key={limit.kind} flexDirection="column">
656 <Box justifyContent="space-between" width="100%" gap={1}>
657 <Text>{rateLimitTitle(limit)}</Text>
658 <Text color={usageColor(limit.percentUsed)} bold>{limit.percentUsed}%</Text>
659 </Box>
660 {progressBar(limit.percentUsed, barWidth, usageColor(limit.percentUsed))}
661 {limit.resetsAt && <Text dimColor>{resetLabel(limit.resetsAt)}</Text>}
662 </Box>
663 )) : (
664 <Text dimColor>Rate-limit usage is not available yet.</Text>
665 )}
666 {detailUsage?.cost && (
667 <Box justifyContent="space-between" width="100%">
668 <Text>Session cost</Text>
669 <Text dimColor>${detailUsage.cost.usd.toFixed(2)}</Text>
670 </Box>
671 )}
672 </Box>
673
674 <Box flexDirection="column" gap={1}>
675 <Text bold>Context makeup</Text>
676 {usedCategories.map(category => {
677 const share = current.totalTokens > 0
678 ? (category.tokens / current.totalTokens) * 100
679 : 0
680
681 return (
682 <Box key={`${category.kind}:${category.name}`} flexDirection="column">
683 <Box justifyContent="space-between" width="100%" gap={1}>
684 <Text wrap="truncate">{category.name}</Text>
685 <Text dimColor>{detailTokenLabel(category.tokens)} · {percentage(category.tokens, current.totalTokens)}</Text>
686 </Box>
687 {progressBar(share, barWidth)}
688 </Box>
689 )
690 })}
691 </Box>
692
693 <Box flexDirection="column" marginTop={1}>
694 <Box justifyContent="space-between" width="100%">
695 <Text bold>Model window</Text>
696 <Text dimColor>{detailTokenLabel(current.totalTokens)} / {tokenLabel(current.rawMaxTokens)}</Text>
697 </Box>
698 {progressBar(windowPercent, barWidth, usageColor(windowPercent))}
699 <Box gap={2} flexWrap="wrap">
700 {freeTokens > 0 && <Text dimColor>Free {detailTokenLabel(freeTokens)}</Text>}
701 {bufferTokens > 0 && <Text dimColor>Buffer {detailTokenLabel(bufferTokens)}</Text>}
702 {deferredTokens > 0 && <Text dimColor>Deferred {detailTokenLabel(deferredTokens)}</Text>}
703 </Box>
704 </Box>
705
706 {mcpServers.length > 0 && (
707 <Box flexDirection="column" gap={1} marginTop={1}>
708 <Box justifyContent="space-between" width="100%">
709 <Text bold>MCP servers</Text>
710 <Text dimColor>{detailTokenLabel(mcpLoadedTokens)} loaded</Text>
711 </Box>
712
713 {mcpServers.slice(0, MCP_SERVER_LIMIT).map(server => {
714 const share = mcpLoadedTokens > 0
715 ? (server.loadedTokens / mcpLoadedTokens) * 100
716 : 0
717
718 return (
719 <Box key={`mcp-server-${server.name}`} flexDirection="column">
720 <Box justifyContent="space-between" width="100%" gap={1}>
721 <Text wrap="truncate">{friendlyName(server.name)}</Text>
722 <Text dimColor>
723 {detailTokenLabel(server.loadedTokens)} · {server.loadedTools}/{server.tools.length} tools
724 </Text>
725 </Box>
726 {progressBar(share, barWidth)}
727 </Box>
728 )
729 })}
730
731 {mcpServers.length > MCP_SERVER_LIMIT && (
732 <Text dimColor>{mcpServers.length - MCP_SERVER_LIMIT} more servers in the picker</Text>
733 )}
734
735 {serverPicker}
736
737 {selectedServer && (
738 <Box flexDirection="column" gap={1}>
739 <Box justifyContent="space-between" alignItems="center" width="100%">
740 <Text bold>{friendlyName(selectedServer.name)} tools</Text>
741 <Box gap={1}>
742 <Text dimColor>
743 {visibleMcpPage * MCP_TOOLS_PER_PAGE + 1}-{Math.min((visibleMcpPage + 1) * MCP_TOOLS_PER_PAGE, selectedServer.tools.length)} of {selectedServer.tools.length}
744 </Text>
745 {visibleMcpPage > 0 && (
746 <Button
747 key="mcp-previous"
748 label="Previous"
749 plain
750 onPress={() => {
751 mcpPage = Math.max(0, visibleMcpPage - 1)
752 $.ui.invalidate('ui.render')
753 }}
754 />
755 )}
756 {visibleMcpPage < maxMcpPage && (
757 <Button
758 key="mcp-next"
759 label="Next"
760 plain
761 onPress={() => {
762 mcpPage = Math.min(maxMcpPage, visibleMcpPage + 1)
763 $.ui.invalidate('ui.render')
764 }}
765 />
766 )}
767 </Box>
768 </Box>
769
770 {visibleMcpTools.map(tool => (
771 <Box key={`${tool.serverName}:${tool.name}`} flexDirection="column">
772 <Box justifyContent="space-between" width="100%" gap={1}>
773 <Text color={tool.isLoaded ? 'success' : 'inactive'} wrap="truncate">
774 {toolName(tool)}
775 </Text>
776 <Text dimColor>
777 {tool.isLoaded ? detailTokenLabel(tool.tokens) : 'deferred'}
778 </Text>
779 </Box>
780 {tool.isLoaded && (
781 progressBar(selectedServer.loadedTokens > 0
782 ? (tool.tokens / selectedServer.loadedTokens) * 100
783 : 0, shortBarWidth)
784 )}
785 </Box>
786 ))}
787
788 {selectedServer.deferredTools > 0 && (
789 <Text dimColor>
790 {selectedServer.deferredTools} deferred schemas are outside active context.
791 </Text>
792 )}
793 </Box>
794 )}
795 </Box>
796 )}
797
798 <Text dimColor>
799 Messages is the conversation total. Per-message counts are not available.
800 </Text>
801 {breakdownError && <Text color="error">Last refresh failed: {breakdownError}</Text>}
802 </Box>
803 )
804 })
805}
806hooks/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