Fork do context-bar (hamzafer, MIT): a janela de contexto como barra empilhada acima do prompt, agora atualizada também depois de um /compact. /context-bar…

claude plugin marketplace add MoriHiroshiDesu/hiroshi-claude-mods
claude plugin install <mod>@hiroshi-claude-mods --scope user
Depois, /reload-plugins ou reinicie o Claude Code. Para trazer versões novas: claude plugin marketplace update.
| Mod | O que faz |
|---|---|
model-effort | Mostra o modelo e o effort da sessão, alinhados à direita do rodapé do prompt (sonnet 5.5 · high). Verde até sonnet e até high; vermelho de opus e de xhigh para cima. |
git-band | Uma linha acima do prompt em 4 colunas: branch ou worktree (↑↓), alterações não commitadas por cor, último commit e as linhas +/- dele. |
context-bar-hiroshi | Fork do context-bar do hamzafer (MIT): a janela de contexto como barra empilhada acima do prompt, agora atualizada também depois de um /compact. /context-bar mostra ou esconde. |
hooks/register.tsx 188 lines1// Fork of hamzafer/claude-code-mods context-bar (MIT). Change: refresh after a /compact reads the new window (session.append, doors compaction, command and notice).
2// Context Bar: what is filling my context window?
3// Above the prompt, the window as one stacked bar, a color per category as /context
4// draws them (system prompt, tools, MCP tools, memory files, skills, messages, free),
5// with a legend of tokens and shares and where auto-compaction runs. It refreshes after
6// each turn. /context-bar shows or hides it, and the choice is kept across sessions.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Reading, Slice } from '../types'
11
12const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
13const SPLIT = ' '
14// /context's theme gives several rows the same grey, so each used row gets its own color, in order.
15const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
16const MESSAGES = '#d97757' // the row that grows, in the accent color
17const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
18const BUFFER = '#808080'
19const GLYPH = { used: '█', free: '─', buffer: '░' } as const
20
21// Held by the host, so the bar survives a hot reload of this file.
22const reading = atom({ plugin: 'context-bar-hiroshi', key: 'reading' } as const, null as Reading | null)
23const isHidden = atom({ plugin: 'context-bar-hiroshi', key: 'isHidden' } as const, false)
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 const r = await next(e)
28 await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
29 const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
30 await update($, isHidden, () => hidden)
31 void refresh($).catch(() => {})
32 return r
33 })
34
35 on('turn.complete', async ($, e, next) => {
36 const r = await next(e)
37 if (!e.agentId) await refresh($).catch(() => {}) // a subagent's turn fills its own window, not this one
38 return r
39 })
40
41 // A /compact empties the window without a turn ending. `session.compact` answers before the
42 // transcript is swapped, and a plugin that does its own compaction may skip the compaction row,
43 // so the rows a compaction leaves behind (its boundary, the command's record, the notice) are
44 // what we read after: by then the transcript is the new one.
45 on('session.append', async ($, e, next) => {
46 const r = await next(e)
47 if (!e.agentId && (e.door === 'compaction' || e.door === 'command' || e.door === 'notice')) await refresh($).catch(() => {})
48 return r
49 })
50
51 on('command.run', { command: 'context-bar' }, async $ => {
52 const hidden = await update($, isHidden, h => !h)
53 await $.store.set('isHidden', hidden).catch(() => {})
54 if (!hidden) await refresh($).catch(() => {})
55 return { text: hidden ? 'Context bar hidden. /context-bar shows it again' : 'Context bar on' }
56 })
57
58 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
59 const rest = await next(e) // what other mods and Claude Code draw here stays, drawn above the box so one-row mods stay visible
60 const r = await read($, reading)
61 if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
62 const { Box, Text } = $.ui.resolve(e)
63 const inner = e.props.bodyColumns - 4 // the border and padding take 4 cells
64 if (inner < MIN_WIDTH) return rest
65
66 const head = `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? ` · compacts at ${tokens(r.compactsAt)}` : ''}`
67 const pct = ` ${r.percent}% `
68 const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
69 return (
70 <Box flexDirection="column">
71 {rest}
72 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
73 <Box flexDirection="row" justifyContent="space-between">
74 <Text wrap="truncate-end">
75 <Text color="#d97757">{'◆ '}</Text>
76 <Text bold>context</Text>
77 </Text>
78 <Text wrap="truncate-start">
79 <Text dimColor>{`${head} `}</Text>
80 <Text bold color="black" backgroundColor={level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'}>{pct}</Text>
81 </Text>
82 </Box>
83 <Text>
84 {cells(r, inner).map(c => (
85 <Text color={c.color}>{c.text}</Text>
86 ))}
87 </Text>
88 {legend(r, inner).map(line => (
89 <Text wrap="truncate-end">
90 {line.map((s, i) => (
91 <Text>
92 {i > 0 && <Text>{SPLIT}</Text>}
93 <Text color={s.color}>{s.kind === 'used' ? '■ ' : `${GLYPH[s.kind]} `}</Text>
94 <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
95 <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
96 {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
97 </Text>
98 ))}
99 </Text>
100 ))}
101 </Box>
102 </Box>
103 )
104 })
105}
106
107// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
108async function refresh($: EngineInterface) {
109 if (await read($, isHidden)) return
110 const usage = await $.session.usage({ breakdown: 'summary' })
111 const b = usage.context.breakdown
112 if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
113 await update($, reading, () => toReading(b))
114}
115
116export function toReading(b: {
117 categories: { name: string; tokens: number; color: string; kind: string }[]
118 totalTokens: number
119 rawMaxTokens: number
120 percentage: number
121 autoCompactThreshold?: number
122 isAutoCompactEnabled: boolean
123}): Reading {
124 const slices: Slice[] = b.categories
125 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
126 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
127 const order = { used: 0, free: 1, buffer: 2 }
128 slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
129 let next = 0
130 for (const s of slices) {
131 s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
132 }
133 return {
134 slices,
135 total: b.totalTokens,
136 window: b.rawMaxTokens,
137 percent: b.percentage,
138 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
139 }
140}
141
142// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
143export function cells(r: Reading, width: number) {
144 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
145 // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
146 let diff = width - sizes.reduce((a, n) => a + n, 0)
147 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
148 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
149 for (const i of order) {
150 if (diff === 0) break
151 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
152 diff -= size - sizes[i]!
153 sizes[i] = size
154 }
155 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
156}
157
158// The legend, packed into lines no wider than `width`.
159export function legend(r: Reading, width: number) {
160 const lines: Slice[][] = [[]]
161 let used = 0
162 for (const s of r.slices) {
163 const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
164 const line = lines.at(-1)!
165 if (line.length > 0 && used + SPLIT.length + size > width) {
166 lines.push([s])
167 used = size
168 } else {
169 used += (line.length > 0 ? SPLIT.length : 0) + size
170 line.push(s)
171 }
172 }
173 return lines.filter(l => l.length > 0)
174}
175
176export function tokens(n: number) {
177 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
178 if (n >= 10_000) return `${Math.round(n / 1000)}k`
179 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
180 return String(n)
181}
182
183export function share(n: number, window: number) {
184 const p = (n / window) * 100
185 if (p > 0 && p < 0.1) return '<0.1%'
186 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
187}
188types/index.d.ts 16 lines1export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2
3export type Reading = {
4 slices: Slice[]
5 total: number // tokens in use
6 window: number // the window measured against
7 percent: number
8 compactsAt?: number // where auto-compaction runs, when it is on
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'context-bar-hiroshi': { reading: Reading | null; isHidden: boolean }
14 }
15}
16