SLOPSHOPPER

session-hud

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

panebandguardprocesstimer
★ 6v0.5.5MITupdated 2026-10-21bradtraversy/traversy-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 [ 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 │ │ │ │ ⟨Claude Code's own drawing⟩ ● feat/auth-refresh Context ██████░░░░░░░░░░░░ 32% of 300k [ Details ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● feat/auth-refresh Context ██████░░░░░░░░░░░░ 32% of 300k [ Details ]
Pane · context-breakdown
Session Details [ Refresh ] Counting context tokens...
README

Session HUD

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.

Session HUD telemetry strip showing branch and context usage

  • Git branch, with a yellow warning when you are on main, master, or a detached HEAD
  • Context usage against a personal target you set
  • Weekly account usage at a glance
  • A Details pane with the full context breakdown, account limits, and session cost

Install

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

How it works

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">

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

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.

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 branch signal, context meter, percentage, weekly usage, and Details control 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. Select Details beside the progress bar.
  8. Confirm that Session Details shows context headroom, 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.

Uninstall

claude plugin uninstall session-hud@traversy-mods

Source

hooks/register.tsx

Back to the mod index

Source 2 files
hooks/register.tsx 806 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 = ''
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}
806
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