One line above the prompt: the context window by /context category and room before compaction on the left, 5-hour and weekly limits on the right; /context-bar…

A Claude Code mod: one line above the prompt, with how full this session's context window is on the left and your account's rate limits on the right.
██████░░░░░░░░░░░▒▒▒ 30% 60k/200k · compacts in 100k (~10 turns) 5-hour 42% resets in 2h14m · weekly 18%
On the left, this session's window
█ what is in the window, in the colours /context gives its categories (system prompt, tools, memory files, messages…), ░ the free space, ▒ the buffer kept for auto-compaction.30% the share of the window in use, coloured by the working zone (below): green, yellow or red.60k/200k tokens in the window out of its size.compacts in 100k how many tokens are left before Claude Code compacts the conversation; absent when auto-compaction is off.(~10 turns) how many of your turns that room lasts at the pace of the last five, so you know whether the task fits before a compact. It shows from the third turn of a conversation and starts over after a compact or /clear; what subagents read does not count, it fills their own window.On the right, your account's rate limits (subscription accounts only)
5-hour 42% and weekly 18% how much of each limit is used: green while there is room, yellow from 75%, red from 90%.resets in 2h14m when that limit starts over, counted down each minute.On a narrow terminal the limits shorten first, then the bar shrinks, then the line gives up, a step at a time, the turns left, compacts in, and the limits; only past that is its end cut:
██████░░░░░░░░░░░▒▒▒ 30% 60k/200k · compacts in 100k 5h 42% ↻2h14m · 7d 18%
██████░░░░░░░░░░░▒▒▒ 30% 60k/200k 5h 42% ↻2h14m · 7d 18%
██████░░░░░░░░░░░▒▒▒ 30% 60k/200k
Other mods that draw rows above the prompt keep them, under this line.
Answers get worse well before the window is full, so the bar watches the share in use:
█████████████░░░▒▒▒▒ 62% 124k/200k · compacts in 36k (~3 turns) 5-hour 42% resets in 2h14m · weekly 18%
⚠ past the working zone (50–60%) h: handoff c: /compact
largest ■ Messages 98k · ■ System tools 16k · ■ Memory files 6k
c puts /compact in the prompt box. h appears when Matt Pocock's handoff skill is installed (the mattpocock-skills plugin) and puts /mattpocock-skills:handoff there, for you to finish with what the next session is for: it writes the conversation to a file in your temp directory that a fresh session can read and pick up. Neither runs until you press Enter. The toast comes back only after the window has dropped under 50% again, say after a compact.
Set the zone with /context-bar zone: a pane with − and + for each threshold (5% a step), presets on 1–4 (40–50, 50–60, 60–70, 70–80), r to go back to 50–60, and your current share shown in the colour it would take. Every change shows on the bar at once and is kept for every session. Without the pane, /context-bar zone 40 70 sets yellow from 40% and red from 70%.
Claude Code keeps the conversation in the prompt cache for an hour on a subscription and five minutes otherwise. Away longer than that, the next reply sends the whole window again uncached, which costs more and counts more against your limits. The bar says so once the time is up:
◌ cache cold after 1h12m idle: the next reply re-sends 124k uncached h: handoff
A handoff into a fresh conversation, then /clear, may cost less than carrying on. The row goes away with the next reply. A mod cannot read the cache setting, so a session with rate limits on the right (a subscription) is taken for the hour.
/context-bar lang ru switches every word the mod shows to Russian, and the choice is kept for every session:
██████░░░░░░░░░░░▒▒▒ 30% 60k/200k · сжатие через 100k (~10 ходов) 5 часов 42% сброс через 2ч14м · неделя 18%
Claude Code names the window's categories in English; the Russian side translates the ones it knows (Messages is Сообщения) and leaves a new one as it is. /context-bar lang en goes back.
When a handoff is written, the bar remembers it for the folder you are in. After /clear, or in the next session started in that folder within a day, a row under the bar offers it:
↩ handoff 5m ago: Claude Code mods r: resume x: dismiss
and the empty prompt box shows Read <file> and pick up from there. (in Russian, Прочитай <file> и продолжи с того места.) as its dim suggestion: Tab takes it, Enter sends it. r puts the same request in the box, x forgets the handoff. Once sent, it is not offered again; any other prompt just puts the row away for that conversation. The row shows even with the bar turned off.
A handoff is the Markdown file written while /handoff runs, or one with handoff in its name in your temp directory.
| Command | What it does |
|---|---|
/context-bar | Hide or show the bar for this session. |
/context-bar details | Open a pane with the full breakdown: each category, memory files, MCP servers and how many of their tools are loaded, the largest skills, custom agents and the cache split of the last API call. r refreshes it. |
/context-bar zone | Open the working-zone pane: −/+ for yellow and red, presets 1–4, r resets. |
/context-bar zone 40 70 | Yellow from 40%, red from 70%, without the pane. |
/context-bar zone reset | Back to 50–60. |
/context-bar lang ru | Speak Russian: the bar, its rows, the panes, the toast and the command's replies; lang en back to English. Kept for every session. |
Keys on the rows under the bar, once the band has the focus (a click, or ctrl+x tab):
| Key | What it does |
|---|---|
h | Put /mattpocock-skills:handoff in the prompt box (when installed), from the red row or the cold-cache row. |
c | Put /compact in the prompt box. |
r | Put the request to read the offered handoff in the prompt box. |
x | Dismiss the offered handoff. |
It reads only the usage numbers Claude Code already has, the same ones /context shows, and sends nothing anywhere. The details pane costs a few extra requests, like running /context, so it refreshes only while it is open.
Needs Claude Code 2.1.281 or later, the first with the session state mods keep their values in; on an older one the mod loads but draws nothing. claude --version tells, claude update updates.
At the prompt of a Claude Code session:
/plugin install context-bar-compact --marketplace Darvl-52/context-bar-compact
y adds the marketplace, then pick a scope. From a shell:
claude plugin install context-bar-compact --marketplace Darvl-52/context-bar-compact
If you have context-bar installed, disable it: both register /context-bar.
Mods are early access, and Claude Code may not load an installed plugin's hooks module: with a third-party provider (Bedrock, Vertex, a gateway) or with telemetry turned off, nothing shows and /context-bar is an unknown command. Set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment Claude Code starts in (your shell profile), then start a new session. claude --debug says why a module was not loaded: search its log for hooks module.
A compact fork of context-bar by Akash Bhatnagar (MIT), which draws the same data over three lines.
hooks/register.tsx 939 lines1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3import type { Details, Handoff, Lang, Limit, Pace, Row, Segment, Snapshot, Thresholds } from '../types'
4
5// Session values, read and written with $.state, which Claude Code has from 2.1.281.
6// On at the start of every session; /context-bar hides it for that session only.
7const isOn = { plugin: 'context-bar-compact', key: 'isOn' } as const
8const snapshot = { plugin: 'context-bar-compact', key: 'snapshot' } as const
9// The time the band last read, bumped each minute so the reset countdowns move.
10const now = { plugin: 'context-bar-compact', key: 'now' } as const
11const details = { plugin: 'context-bar-compact', key: 'details' } as const
12// The working zone in force: what /context-bar zone saved, else the defaults.
13const zoneState = { plugin: 'context-bar-compact', key: 'zone' } as const
14// The handoff offered above the prompt, until it is taken up, dismissed or another prompt is sent.
15const offer = { plugin: 'context-bar-compact', key: 'handoff' } as const
16// Set at the session's first start: a reload runs session.start again, and must not offer the
17// handoff a second time.
18const hasStarted = { plugin: 'context-bar-compact', key: 'hasStarted' } as const
19// How the window grew over the last turns, for the turns left before compaction.
20const paceState = { plugin: 'context-bar-compact', key: 'pace' } as const
21// When the main loop last had a reply: the prompt cache expires counted from it.
22const repliedAt = { plugin: 'context-bar-compact', key: 'repliedAt' } as const
23// The language in force: what /context-bar lang saved, else English.
24const langState = { plugin: 'context-bar-compact', key: 'lang' } as const
25
26const PANE = 'context-details'
27const ZONE_PANE = 'context-zone'
28const ZONE_KEY = 'zone'
29const LANG_KEY = 'lang'
30const ZONE_STEP = 5
31// The latest handoff per folder, kept in the store so a new process finds it too.
32const HANDOFFS_KEY = 'handoffs'
33// A handoff older than this is no longer offered.
34const HANDOFF_MAX_AGE_MS = 24 * 3_600_000
35export const PRESETS: readonly Thresholds[] = [
36 { warnAt: 40, alertAt: 50 },
37 { warnAt: 50, alertAt: 60 },
38 { warnAt: 60, alertAt: 70 },
39 { warnAt: 70, alertAt: 80 },
40]
41const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
42const BAR_CELLS = 20
43// The narrowest the bar gets before the figures beside it give way.
44const BAR_MIN_CELLS = 8
45const REFRESH_MS = 3000
46const TICK_MS = 60_000
47const LIMIT_WARN_AT = 75
48const LIMIT_ALERT_AT = 90
49// The least room kept between the window's figures and the rate limits.
50const GAP = 3
51// The turns averaged for the pace, and the fewest counted before turns left are shown.
52const PACE_TURNS = 5
53const PACE_MIN_TURNS = 2
54// Claude Code keeps the prompt cache an hour on a subscription and five minutes otherwise. A mod
55// cannot read the setting, so a session with rate limits, a subscription's, is taken for the hour.
56const CACHE_TTL_SUBSCRIPTION_MS = 3_600_000
57const CACHE_TTL_MS = 5 * 60_000
58// The categories named on the row under a yellow or red bar.
59const LARGEST = 3
60// The working zone of the window: from `warnAt` the figure turns yellow, from `alertAt` red,
61// with a toast once and a row offering a handoff or /compact. Set with /context-bar zone and kept
62// in the plugin's store between sessions; these are the defaults.
63export const DEFAULT_THRESHOLDS: Thresholds = { warnAt: 50, alertAt: 60 }
64
65// A value that is no number, or out of 1–100, falls back to its default; a pair given the wrong
66// way round is swapped rather than refused, the intent being plain.
67export const thresholdsOf = (warn: unknown, alert: unknown): Thresholds => {
68 const pick = (value: unknown, fallback: number) =>
69 typeof value === 'number' && Number.isFinite(value) && value >= 1 && value <= 100 ? Math.round(value) : fallback
70 const warnAt = pick(warn, DEFAULT_THRESHOLDS.warnAt)
71 const alertAt = pick(alert, DEFAULT_THRESHOLDS.alertAt)
72 return alertAt >= warnAt ? { warnAt, alertAt } : { warnAt: alertAt, alertAt: warnAt }
73}
74
75// One step of a −/+ button: the other threshold moves along where it would be crossed, so the
76// pair stays in order without a press being refused.
77export const nudge = (t: Thresholds, which: keyof Thresholds, delta: number): Thresholds => {
78 const value = Math.min(100, Math.max(1, t[which] + delta))
79 if (which === 'warnAt') return { warnAt: value, alertAt: Math.max(t.alertAt, value) }
80 return { warnAt: Math.min(t.warnAt, value), alertAt: value }
81}
82
83// `/context-bar zone`, `zone 40 70`, `zone reset`: what the arguments after `zone` ask for.
84export const parseZone = (args: string): 'open' | 'reset' | Thresholds | undefined => {
85 const words = args.trim().split(/\s+/).filter(Boolean)
86 if (words.length === 0) return 'open'
87 if (words.length === 1 && /^(reset|default)$/i.test(words[0]!)) return 'reset'
88 const nums = words.map(w => Number(w.replace(/%$/, '')))
89 if (nums.length === 2 && nums.every(n => Number.isFinite(n) && n >= 1 && n <= 100)) return thresholdsOf(nums[0], nums[1])
90 return undefined
91}
92
93export type Zone = 'ok' | 'warn' | 'over'
94export const zoneOf = (percent: number, t: Thresholds = DEFAULT_THRESHOLDS): Zone =>
95 percent >= t.alertAt ? 'over' : percent >= t.warnAt ? 'warn' : 'ok'
96const ZONE_COLOR: Record<Zone, string> = { ok: 'success', warn: 'warning', over: 'error' }
97
98// Russian plural: 1 ход, 2 хода, 5 ходов.
99const plural = (n: number, one: string, few: string, many: string) => {
100 const ten = n % 10
101 const hundred = n % 100
102 return ten === 1 && hundred !== 11 ? one : ten >= 2 && ten <= 4 && (hundred < 12 || hundred > 14) ? few : many
103}
104
105// Every word the mod shows. English is the default; `/context-bar lang ru` switches to Russian for
106// every session. Claude Code names the window's categories in English; Russian translates the
107// ones it knows and keeps the rest.
108const EN = {
109 units: { m: 'm', h: 'h', d: 'd' },
110 compactsIn: 'compacts in',
111 turns: (n: number) => (n < 1 ? '<1 turn' : `~${n} turn${n === 1 ? '' : 's'}`),
112 limitLong: { five_hour: '5-hour', seven_day: 'weekly', spend_limit: 'spend' } as Record<string, string>,
113 limitShort: { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } as Record<string, string>,
114 resetsIn: 'resets in',
115 category: (name: string) => name,
116 handoffAt: (ago?: string) => (ago === undefined ? 'just now' : `${ago} ago`),
117 resume: 'resume',
118 dismiss: 'dismiss',
119 pastZone: (t: Thresholds) => `⚠ past the working zone (${t.warnAt}–${t.alertAt}%)`,
120 largest: 'largest',
121 cold: (idle: string, tokens: string) => `◌ cache cold after ${idle} idle: the next reply re-sends ${tokens} uncached`,
122 toast: (percent: number, hasHandoff: boolean) =>
123 `Context ${percent}%: past the working zone. ${hasHandoff ? 'Handoff or /compact' : '/compact'} from the row above the prompt.`,
124 resumeText: (path: string) => `Read ${path} and pick up from there.`,
125 description: 'Toggle the context bar; details: token breakdown; zone: set where it turns yellow and red; lang: en or ru',
126 barOn: 'Context bar on.',
127 barOff: 'Context bar off.',
128 detailsOpened: 'Context details opened.',
129 detailsTitle: 'Context details',
130 zoneUsage: 'Usage: /context-bar zone, /context-bar zone 50 60, /context-bar zone reset',
131 zoneOpened: 'Working zone opened.',
132 zoneTitle: 'Working zone',
133 zoneSet: (t: Thresholds) => `Working zone: yellow from ${t.warnAt}%, red from ${t.alertAt}%.`,
134 langUsage: 'Usage: /context-bar lang en, /context-bar lang ru',
135 langSet: 'Language: English.',
136 yellowFrom: 'Yellow from',
137 redFrom: 'Red from ',
138 presets: 'Presets',
139 nowLabel: 'Now ',
140 zoneNow: { ok: ': under the zone', warn: ': yellow', over: ': red, the warning row and a toast' } as Record<Zone, string>,
141 resetTo: (t: Thresholds) => `Reset to ${t.warnAt}–${t.alertAt}`,
142 savedHint: 'Saved for every session · Esc closes',
143 counting: 'Counting tokens…',
144 refresh: 'Refresh',
145 categories: 'Categories',
146 onDemand: 'loaded on demand',
147 memoryFiles: 'Memory files',
148 mcpServers: 'MCP servers',
149 skills: 'Skills',
150 customAgents: 'Custom agents',
151 slashCommands: 'Slash commands',
152 toolsLoaded: (loaded: number, tools: number) => `${loaded}/${tools} tools loaded`,
153 skillsNote: (included: number, total: number, tokens: string) => `${included}/${total} listed, ${tokens} in all; largest shown`,
154 commandsNote: (included: number, total: number, tokens: string) => `${included}/${total} listed, ${tokens} tokens`,
155 lastCall: 'Last API call',
156 apiLine: (read: string, write: string, uncached: string, output: string) =>
157 `cache read ${read} · cache write ${write} · uncached ${uncached} · output ${output}`,
158}
159
160const CATEGORY_RU: Record<string, string> = {
161 'System prompt': 'Системный промпт',
162 'System tools': 'Системные инструменты',
163 'MCP tools': 'MCP-инструменты',
164 'Custom agents': 'Свои агенты',
165 'Memory files': 'Файлы памяти',
166 Skills: 'Скиллы',
167 Messages: 'Сообщения',
168 'Free space': 'Свободно',
169 'Autocompact buffer': 'Буфер автосжатия',
170}
171
172const RU: typeof EN = {
173 units: { m: 'м', h: 'ч', d: 'д' },
174 compactsIn: 'сжатие через',
175 turns: n => (n < 1 ? '<1 хода' : `~${n} ${plural(n, 'ход', 'хода', 'ходов')}`),
176 limitLong: { five_hour: '5 часов', seven_day: 'неделя', spend_limit: 'расход' },
177 limitShort: { five_hour: '5ч', seven_day: '7д', spend_limit: 'расход' },
178 resetsIn: 'сброс через',
179 category: name => CATEGORY_RU[name] ?? name,
180 handoffAt: ago => (ago === undefined ? 'только что' : `${ago} назад`),
181 resume: 'продолжить',
182 dismiss: 'убрать',
183 pastZone: t => `⚠ за рабочей зоной (${t.warnAt}–${t.alertAt}%)`,
184 largest: 'больше всего',
185 cold: (idle, tokens) => `◌ кэш остыл, простой ${idle}: следующий ответ заново отправит ${tokens} без кэша`,
186 toast: (percent, hasHandoff) =>
187 `Контекст ${percent}%: за рабочей зоной. ${hasHandoff ? 'Handoff или /compact' : '/compact'} — в строке над полем ввода.`,
188 resumeText: path => `Прочитай ${path} и продолжи с того места.`,
189 description: 'Полоса контекста вкл/выкл; details: разбивка токенов; zone: где желтеет и краснеет; lang: en или ru',
190 barOn: 'Полоса контекста включена.',
191 barOff: 'Полоса контекста выключена.',
192 detailsOpened: 'Детали контекста открыты.',
193 detailsTitle: 'Детали контекста',
194 zoneUsage: 'Как вызвать: /context-bar zone, /context-bar zone 50 60, /context-bar zone reset',
195 zoneOpened: 'Рабочая зона открыта.',
196 zoneTitle: 'Рабочая зона',
197 zoneSet: t => `Рабочая зона: жёлтая от ${t.warnAt}%, красная от ${t.alertAt}%.`,
198 langUsage: 'Как вызвать: /context-bar lang en, /context-bar lang ru',
199 langSet: 'Язык: русский.',
200 yellowFrom: 'Жёлтая от ',
201 redFrom: 'Красная от',
202 presets: 'Пресеты',
203 nowLabel: 'Сейчас ',
204 zoneNow: { ok: ': ниже зоны', warn: ': жёлтая', over: ': красная, строка-предупреждение и уведомление' },
205 resetTo: t => `Сбросить на ${t.warnAt}–${t.alertAt}`,
206 savedHint: 'Сохраняется для всех сессий · Esc закрывает',
207 counting: 'Считаю токены…',
208 refresh: 'Обновить',
209 categories: 'Категории',
210 onDemand: 'загружается по запросу',
211 memoryFiles: 'Файлы памяти',
212 mcpServers: 'MCP-серверы',
213 skills: 'Скиллы',
214 customAgents: 'Свои агенты',
215 slashCommands: 'Слэш-команды',
216 toolsLoaded: (loaded, tools) => `загружено ${loaded} из ${tools} инструментов`,
217 skillsNote: (included, total, tokens) => `${included} из ${total} в списке, ${tokens} всего; показаны крупнейшие`,
218 commandsNote: (included, total, tokens) => `${included} из ${total} в списке, ${tokens} токенов`,
219 lastCall: 'Последний вызов API',
220 apiLine: (read, write, uncached, output) => `чтение кэша ${read} · запись в кэш ${write} · без кэша ${uncached} · ответ ${output}`,
221}
222
223export type Words = typeof EN
224export const WORDS: Record<Lang, Words> = { en: EN, ru: RU }
225
226// Matt Pocock's handoff skill, when its plugin is installed: the command's name as typed.
227export const findHandoff = (commands: readonly { name: string; plugin?: string }[]) =>
228 commands.find(c => c.plugin === 'mattpocock-skills' && /(^|:)handoff$/.test(c.name))?.name ??
229 commands.find(c => /(^|:)handoff$/.test(c.name))?.name
230
231export const short = (n: number) =>
232 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
233
234// The band's figures: whole thousands from 10k up, so the line stays short.
235export const tiny = (n: number) =>
236 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : short(n)
237
238export const span = (ms: number, w: Words = EN) => {
239 const { m: mu, h: hu, d: du } = w.units
240 const m = Math.max(0, Math.floor(ms / 60_000))
241 if (m < 60) return `${m}${mu}`
242 const hours = Math.floor(m / 60)
243 if (hours < 24) return `${hours}${hu}${m % 60}${mu}`
244 return `${Math.floor(hours / 24)}${du}${hours % 24}${hu}`
245}
246
247// The pace after a turn ends at `total` tokens. A smaller window than the last turn's (a compact,
248// a /clear) starts it over; a turn that added nothing is not counted.
249export const nextPace = (pace: Pace | null, total: number): Pace => {
250 if (pace === null || total < pace.lastTotal) return { lastTotal: total, deltas: [] }
251 const grew = total - pace.lastTotal
252 return { lastTotal: total, deltas: grew > 0 ? [...pace.deltas, grew].slice(-PACE_TURNS) : pace.deltas }
253}
254
255// Whole turns at the recent pace before `compactIn` runs out; unknown until the pace has a few.
256export const turnsLeft = (compactIn: number, pace: Pace | null) => {
257 if (pace === null || pace.deltas.length < PACE_MIN_TURNS) return undefined
258 return Math.floor(compactIn / (pace.deltas.reduce((a, b) => a + b, 0) / pace.deltas.length))
259}
260
261// The figures beside the bar, all about this session's window: its size, then room before
262// auto-compaction, and how many turns that room lasts at the recent pace.
263export const contextLine = (snap: Snapshot, turns?: number, w: Words = EN) =>
264 ` ${tiny(snap.total)}/${tiny(snap.max)}` +
265 (snap.compactIn !== undefined ? ` · ${w.compactsIn} ${tiny(snap.compactIn)}${turns !== undefined ? ` (${w.turns(turns)})` : ''}` : '')
266
267// What fills the window most, for the row under a yellow or red bar.
268export const largest = (segments: readonly Segment[]) =>
269 segments
270 .filter(s => s.kind === 'used')
271 .sort((a, b) => b.tokens - a.tokens)
272 .slice(0, LARGEST)
273
274export const cacheTtlOf = (limits: readonly Limit[]) => (limits.length > 0 ? CACHE_TTL_SUBSCRIPTION_MS : CACHE_TTL_MS)
275
276export type LimitPart = { label: string; percent: number; reset: string }
277// The account's rate limits, drawn apart from the window at the right end. Spelled out
278// (`5-hour`, `resets in`) where the line has room, in the short form (`5h`, `↻`) where it has not.
279export const limitParts = (limits: readonly Limit[], at: number, isShort: boolean, w: Words = EN): LimitPart[] =>
280 limits.map(l => {
281 const label = (isShort ? w.limitShort : w.limitLong)[l.kind] ?? l.kind
282 const left = l.resetsAt !== undefined && l.resetsAt > at ? span(l.resetsAt - at, w) : undefined
283 return { label, percent: l.percentUsed, reset: left === undefined ? '' : isShort ? ` ↻${left}` : ` ${w.resetsIn} ${left}` }
284 })
285
286export const limitsText = (parts: readonly LimitPart[]) => parts.map(p => `${p.label} ${p.percent}%${p.reset}`).join(' · ')
287
288export type Layout = { context: string; limits: LimitPart[]; width: number }
289// The widest line that fits in `columns`: the long labels beside a full bar, else the short labels
290// and a bar narrowed to the room left; then, a step at a time, without the turns left, without
291// the room before compaction, without the limits. Past that the line is cut at its end.
292export const layoutOf = (snap: Snapshot, turns: number | undefined, at: number, columns: number, w: Words = EN): Layout => {
293 const bare: Snapshot = { ...snap }
294 delete bare.compactIn
295 const steps = [
296 { isShort: false, context: contextLine(snap, turns, w), hasLimits: true, least: BAR_CELLS },
297 { isShort: true, context: contextLine(snap, turns, w), hasLimits: true, least: BAR_MIN_CELLS },
298 { isShort: true, context: contextLine(snap, undefined, w), hasLimits: true, least: BAR_MIN_CELLS },
299 { isShort: true, context: contextLine(bare, undefined, w), hasLimits: true, least: BAR_MIN_CELLS },
300 { isShort: true, context: contextLine(bare, undefined, w), hasLimits: false, least: BAR_MIN_CELLS },
301 ]
302 const figure = ` ${snap.percent}%`.length
303 let layout: Layout = { context: '', limits: [], width: BAR_MIN_CELLS }
304 for (const step of steps) {
305 const limits = step.hasLimits ? limitParts(snap.limits, at, step.isShort, w) : []
306 const right = limitsText(limits).length
307 const room = columns - figure - step.context.length - (right > 0 ? GAP + right : 0)
308 layout = { context: step.context, limits, width: Math.max(BAR_MIN_CELLS, Math.min(BAR_CELLS, room)) }
309 if (room >= step.least) break
310 }
311 return layout
312}
313
314// A rate limit reads green while there is room, yellow as it runs short, red near the end.
315export const limitColor = (percent: number) => (percent >= LIMIT_ALERT_AT ? 'error' : percent >= LIMIT_WARN_AT ? 'warning' : 'success')
316
317// Split `width` cells across segments by largest remainder, giving every
318// non-empty used segment at least one cell so small categories stay visible.
319export const allocate = (segments: Segment[], max: number, width: number): number[] => {
320 if (max <= 0 || width <= 0) return segments.map(() => 0)
321 const exact = segments.map(s => (s.tokens / max) * width)
322 const cells = exact.map((x, i) => (segments[i]!.kind === 'used' && segments[i]!.tokens > 0 ? Math.max(1, Math.floor(x)) : Math.floor(x)))
323 let spare = width - cells.reduce((a, b) => a + b, 0)
324 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
325 for (let k = 0; spare > 0 && order.length > 0; k = (k + 1) % order.length, spare--) cells[order[k]![1]]! += 1
326 // Over budget from the minimum-one rule: take cells back from the free space, then the largest.
327 while (spare < 0) {
328 const free = segments.findIndex((s, i) => s.kind === 'free' && cells[i]! > 0)
329 const i = free >= 0 ? free : cells.indexOf(Math.max(...cells))
330 cells[i]! -= 1
331 spare++
332 }
333 return cells
334}
335
336const toLimit = (l: SessionRateLimit): Limit => {
337 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
338 return Number.isNaN(at) ? { kind: l.kind, percentUsed: l.percentUsed } : { kind: l.kind, percentUsed: l.percentUsed, resetsAt: at }
339}
340
341// The last two parts of a path, so memory files read as `.claude/CLAUDE.md`.
342const tail = (path: string) => {
343 const parts = path.split(/[\\/]+/).filter(Boolean)
344 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')
345}
346
347const byTokens = (a: Row, b: Row) => b.tokens - a.tokens
348
349// A path read on any machine: either separator, and a Windows path (a drive letter or a backslash)
350// in one case, as its file system ignores case.
351const isWindowsPath = (path: string) => /^[A-Za-z]:/.test(path) || path.includes('\\')
352const slashed = (text: string, isWindows: boolean) => {
353 const one = text.replace(/[\\/]+/g, '/')
354 return isWindows ? one.toLowerCase() : one
355}
356// The one spelling of a folder or file, for keys and comparisons.
357export const pathKey = (path: string) => slashed(path, isWindowsPath(path)).replace(/(?<=.)\/$/, '')
358// Whether a prompt names `path`, however its slashes or, on Windows, its letters were typed.
359export const mentionsPath = (text: string, path: string) => slashed(text, isWindowsPath(path)).includes(pathKey(path))
360
361// The handoff skill names its file freely; a Markdown file written while it runs, or one in a
362// temporary directory with `handoff` in its name, is taken for one.
363export const isHandoffFile = (path: string, isRunning: boolean) =>
364 /\.md$/i.test(path) && (isRunning || (/handoff/i.test(path.split(/[\\/]/).pop() ?? '') && /([\\/](T|tmp|Temp)[\\/])/i.test(path)))
365
366// The document's first heading, without a leading `Handoff:`; else the file's name.
367export const handoffTitle = (content: string, path: string) => {
368 const heading = /^#\s+(.+)$/m.exec(content)?.[1]?.trim()
369 const title = heading?.replace(/^handoff\s*[:—–-]\s*/i, '').trim()
370 return title || (path.split(/[\\/]/).pop() ?? path).replace(/\.md$/i, '')
371}
372
373export const resumeText = (path: string, w: Words = EN) => w.resumeText(path)
374
375// The language, zone and handoff offer in force, kept by the module too: a /clear empties $.state
376// and the module lives on, so a read falls back here until the values are set again.
377const kept: { lang: Lang; zone: Thresholds; offer: Handoff | null } = { lang: 'en', zone: DEFAULT_THRESHOLDS, offer: null }
378
379const wordsIn = async ($: EngineInterface) => WORDS[(await $.state.get(langState)).value ?? kept.lang]
380
381// The handoff on offer; never written in this conversation's $.state, what the module offered.
382const offered = async ($: EngineInterface) => {
383 const { value } = await $.state.get(offer)
384 return value === undefined ? kept.offer : value
385}
386
387const setOffer = async ($: EngineInterface, next: Handoff | null) => {
388 kept.offer = next
389 await $.state.set(offer, next)
390}
391
392// The engine's record of the details pane rather than a flag of the module's, which a /clear (the
393// pane still up) or a failed ui.close hook would leave wrong.
394const isDetailsUp = async ($: EngineInterface) => ((await $.ui.panes().catch(() => undefined)) ?? []).some(p => p.id === PANE)
395
396let lastFetch = 0
397let isFetching = false
398let isFetchingDetails = false
399let tick: { cancel: () => void } | undefined
400let handoff: string | undefined
401// Raised once past the zone; lowered when the window drops back under its start, after a compact.
402let hasAlerted = false
403// True from a /handoff until its turn completes: the Markdown file written meanwhile is the handoff.
404let isHandingOff = false
405
406async function refresh($: EngineInterface, force = false) {
407 const at = await $.clock.now()
408 if (isFetching || (!force && at - lastFetch < REFRESH_MS)) return
409 if (!((await $.state.get(isOn)).value ?? true)) return
410 isFetching = true
411 lastFetch = at
412 try {
413 const usage = await $.session.usage({ breakdown: 'summary' })
414 const b = usage.context.breakdown
415 if (!b) return
416 const segments: Segment[] = b.categories
417 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
418 .map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind as Segment['kind'] }))
419 const next: Snapshot = {
420 segments,
421 total: b.totalTokens,
422 max: b.rawMaxTokens,
423 percent: b.percentage,
424 startedAt: usage.startedAt,
425 limits: usage.rateLimits.map(toLimit),
426 }
427 if (b.isAutoCompactEnabled && b.autoCompactThreshold !== undefined) next.compactIn = Math.max(0, b.autoCompactThreshold - b.totalTokens)
428 // Time first, so the new snapshot never draws against a stale clock.
429 await $.state.set(now, at)
430 await $.state.set(snapshot, next)
431 const zone = zoneOf(next.percent, (await $.state.get(zoneState)).value ?? kept.zone)
432 if (zone === 'over' && !hasAlerted) {
433 hasAlerted = true
434 $.ui.toast((await wordsIn($)).toast(next.percent, handoff !== undefined))
435 } else if (zone === 'ok') hasAlerted = false
436 } finally {
437 isFetching = false
438 }
439}
440
441// The full breakdown counts every tool and memory file, as /context does, so it
442// is read only for the details pane: on opening it and after each turn while it is open.
443async function refreshDetails($: EngineInterface) {
444 if (isFetchingDetails) return
445 isFetchingDetails = true
446 try {
447 const b = (await $.session.usage({ breakdown: 'full' })).context.breakdown
448 if (!b) return
449 const w = await wordsIn($)
450 const servers = new Map<string, { tokens: number; tools: number; loaded: number }>()
451 for (const t of b.mcpTools) {
452 const s = servers.get(t.serverName) ?? { tokens: 0, tools: 0, loaded: 0 }
453 servers.set(t.serverName, { tokens: s.tokens + t.tokens, tools: s.tools + 1, loaded: s.loaded + (t.isLoaded ? 1 : 0) })
454 }
455 const next: Details = {
456 model: b.model,
457 max: b.rawMaxTokens,
458 total: b.totalTokens,
459 categories: b.categories.filter(c => c.tokens > 0).map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind })),
460 memoryFiles: b.memoryFiles.map(f => ({ name: tail(f.path), tokens: f.tokens, note: f.type })).sort(byTokens),
461 mcpServers: [...servers]
462 .map(([name, s]) => ({ name, tokens: s.tokens, note: w.toolsLoaded(s.loaded, s.tools) }))
463 .sort(byTokens),
464 agents: b.agents.map(a => ({ name: a.agentType, tokens: a.tokens, note: a.source })).sort(byTokens),
465 api: b.apiUsage && {
466 uncached: b.apiUsage.input_tokens,
467 cacheRead: b.apiUsage.cache_read_input_tokens,
468 cacheWrite: b.apiUsage.cache_creation_input_tokens,
469 output: b.apiUsage.output_tokens,
470 },
471 }
472 if (b.skills)
473 next.skills = {
474 total: b.skills.totalSkills,
475 included: b.skills.includedSkills,
476 tokens: b.skills.tokens,
477 top: b.skills.skillFrontmatter
478 .map(s => ({ name: s.name, tokens: s.tokens, note: s.pluginName ?? s.source }))
479 .sort(byTokens)
480 .slice(0, 10),
481 }
482 if (b.slashCommands)
483 next.commands = { total: b.slashCommands.totalCommands, included: b.slashCommands.includedCommands, tokens: b.slashCommands.tokens }
484 await $.state.set(details, next)
485 } finally {
486 isFetchingDetails = false
487 }
488}
489
490// After a turn of the main loop: a fresh read, the window's growth for the pace, and, when the
491// model answered, the time the prompt cache counts from.
492async function afterTurn($: EngineInterface, hasReplied: boolean) {
493 await refresh($, true)
494 const snap = (await $.state.get(snapshot)).value ?? null
495 if (snap) await $.state.set(paceState, nextPace((await $.state.get(paceState)).value ?? null, snap.total))
496 if (hasReplied) await $.state.set(repliedAt, await $.clock.now())
497}
498
499async function openDetails($: EngineInterface) {
500 await $.ui.open({ id: PANE, title: (await wordsIn($)).detailsTitle })
501 await refreshDetails($).catch(() => {})
502}
503
504async function setZone($: EngineInterface, t: Thresholds | 'reset') {
505 const next = t === 'reset' ? DEFAULT_THRESHOLDS : t
506 kept.zone = next
507 await $.state.set(zoneState, next)
508 if (t === 'reset') await $.store.delete(ZONE_KEY)
509 else await $.store.set(ZONE_KEY, next)
510 // The toast is armed again for the new zone.
511 hasAlerted = false
512}
513
514const savedHandoffs = async ($: EngineInterface) =>
515 ((await $.store.get(HANDOFFS_KEY).catch(() => undefined)) as Record<string, Handoff> | undefined) ?? {}
516
517// What a fresh start in this folder is offered: its latest handoff, if recent and still on disk.
518// The prompt box shows the request to read it as its dim suggestion, Tab to take.
519async function offerHandoff($: EngineInterface, cwd: string) {
520 const saved = (await savedHandoffs($))[pathKey(cwd)]
521 const at = await $.clock.now()
522 const isLive = saved !== undefined && at - saved.at < HANDOFF_MAX_AGE_MS && (await $.fs.exists(saved.path).catch(() => false))
523 await setOffer($, isLive ? saved : null)
524 if (isLive) {
525 const text = resumeText(saved.path, await wordsIn($))
526 void $.clock.sleep(300).then(() => $.prompt.suggest({ text })).catch(() => {})
527 }
528}
529
530async function forgetHandoff($: EngineInterface, spent: Handoff) {
531 const all = await savedHandoffs($)
532 const key = pathKey(spent.cwd)
533 if (all[key] !== undefined && pathKey(all[key].path) === pathKey(spent.path)) {
534 delete all[key]
535 await $.store.set(HANDOFFS_KEY, all)
536 }
537 await setOffer($, null)
538}
539
540// What the session starts from, read again from the store: the language and the zone. Run at the
541// start and after a /clear or /resume, which empty $.state and fire no session.start.
542async function restore($: EngineInterface) {
543 const lang = await $.store.get(LANG_KEY).catch(() => undefined)
544 kept.lang = lang === 'ru' ? 'ru' : 'en'
545 await $.state.set(langState, kept.lang)
546 const saved = (await $.store.get(ZONE_KEY).catch(() => undefined)) as Partial<Thresholds> | undefined
547 kept.zone = saved ? thresholdsOf(saved.warnAt, saved.alertAt) : DEFAULT_THRESHOLDS
548 await $.state.set(zoneState, kept.zone)
549}
550
551// The next conversation of the same process, after a /clear or a /resume: what it starts from read
552// again, the pace and the cache's clock started over after a /clear, and the bar and an open details
553// pane drawn afresh. Safe to run twice for one change.
554async function startOver($: EngineInterface, isClear: boolean) {
555 await restore($)
556 await $.state.set(hasStarted, true)
557 if (isClear) {
558 // A new conversation: no pace yet, and no cache of its own to lose.
559 await $.state.set(paceState, null)
560 await $.state.set(repliedAt, 0)
561 }
562 await refresh($, true).catch(() => {})
563 if (await isDetailsUp($)) void refreshDetails($).catch(() => {})
564}
565
566export const register: Register = on => {
567
568 on('session.start', async ($, e, next) => {
569 await restore($)
570 await $.command.register({
571 name: 'context-bar',
572 description: (await wordsIn($)).description,
573 argumentHint: '[details | zone [<yellow%> <red%> | reset] | lang en|ru]',
574 })
575 // Only a session that draws the band needs the minute ticker; a reload drops
576 // the old module's timers, and the cancel covers session.start firing twice in one load.
577 tick?.cancel()
578 if (e.isInteractive)
579 tick = $.clock.every(TICK_MS, () => {
580 void $.clock.now().then(at => $.state.set(now, at))
581 })
582 handoff = findHandoff(await $.command.list().catch(() => []))
583 // Draw the bar as soon as the session opens; never let a failed read block startup.
584 await refresh($, true).catch(() => {})
585 const isReload = (await $.state.get(hasStarted)).value ?? false
586 await $.state.set(hasStarted, true)
587 const root = await $.session.root().catch(() => undefined)
588 if (e.isInteractive && !isReload && root !== undefined) await offerHandoff($, root).catch(() => {})
589 return next(e)
590 })
591
592 // A /clear or a /resume starts no session.start: it ends the session, and that end is where the
593 // next conversation is set up, everywhere, an organization's managed install included. After a
594 // /clear it is offered the handoff just written.
595 on('session.end', async ($, e, next) => {
596 const ran = await next(e)
597 if (e.reason !== 'clear' && e.reason !== 'resume') return ran
598 await startOver($, e.reason === 'clear')
599 const root = e.reason === 'clear' ? await $.session.root().catch(() => undefined) : undefined
600 if (root !== undefined) await offerHandoff($, root).catch(() => {})
601 return ran
602 }).catch(($, e, next) => next(e))
603
604 // The same again once the next conversation has begun (a compaction too), where settings hooks
605 // run, not under an organization's management: should $.state be emptied after session.end, the
606 // values are set in the conversation that reads them. The offer stays session.end's, kept by the
607 // module. Without it the bar reads a compaction at the next session.measure or turn.
608 on('classic.SessionStart', async ($, e, next) => {
609 const ran = await next(e)
610 if (e.source !== 'startup') await startOver($, e.source === 'clear')
611 return ran
612 }).catch(($, e, next) => next(e))
613
614 on('prompt.submit', async ($, e, next) => {
615 if (e.command && /(^|:)handoff$/.test(e.command.name)) isHandingOff = true
616 const waiting = await offered($)
617 // Taken up, it is spent; any other prompt only puts it away for this conversation.
618 if (waiting && !e.isMeta) {
619 if (mentionsPath(e.text, waiting.path)) await forgetHandoff($, waiting)
620 else await setOffer($, null)
621 }
622 return next(e)
623 }).catch(($, e, next) => next(e))
624
625 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
626 const ran = await next(e)
627 if (ran.deny === undefined && !ran.isError && isHandoffFile(e.file_path, isHandingOff)) {
628 // The project root, not the shell's directory: a `cd` during the session would file the
629 // handoff under a folder the next session never starts in.
630 // Filed under one spelling of it, so `D:\proj` and `d:/proj` are the same folder.
631 const cwd = await $.session.root()
632 const written: Handoff = { path: e.file_path, cwd, at: await $.clock.now(), title: handoffTitle(e.content, e.file_path) }
633 await $.store.set(HANDOFFS_KEY, { ...(await savedHandoffs($)), [pathKey(cwd)]: written })
634 }
635 return ran
636 }).catch(($, e, next) => next(e))
637
638 on('command.run', { command: 'context-bar' }, async ($, e) => {
639 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
640 const w = await wordsIn($)
641 if (verb.toLowerCase() === 'details') {
642 await openDetails($)
643 return { text: w.detailsOpened }
644 }
645 if (verb.toLowerCase() === 'zone') {
646 const asked = parseZone(rest.join(' '))
647 if (asked === undefined) return { text: w.zoneUsage }
648 if (asked === 'open') {
649 await $.ui.open({ id: ZONE_PANE, title: w.zoneTitle, focus: true, closeOnEscape: true })
650 return { text: w.zoneOpened }
651 }
652 await setZone($, asked)
653 return { text: w.zoneSet((await $.state.get(zoneState)).value ?? kept.zone) }
654 }
655 if (verb.toLowerCase() === 'lang') {
656 const asked = rest[0]?.toLowerCase()
657 if (asked !== 'en' && asked !== 'ru') return { text: w.langUsage }
658 kept.lang = asked
659 await $.state.set(langState, asked)
660 await $.store.set(LANG_KEY, asked)
661 // The breakdown's notes are worded when read: read it again in the new language.
662 if (await isDetailsUp($)) void refreshDetails($).catch(() => {})
663 return { text: WORDS[asked].langSet }
664 }
665 const nextOn = !((await $.state.get(isOn)).value ?? true)
666 await $.state.set(isOn, nextOn)
667 if (nextOn) await refresh($, true)
668 return { text: nextOn ? w.barOn : w.barOff }
669 })
670
671 on('session.measure', ($, e, next) => {
672 void refresh($)
673 return next(e)
674 })
675
676 on('turn.complete', async ($, e, next) => {
677 // A subagent's turns fill its own window, not this one.
678 if (e.agentId !== undefined) return next(e)
679 isHandingOff = false
680 void afterTurn($, e.usage !== undefined).catch(() => {})
681 if (await isDetailsUp($)) void refreshDetails($).catch(() => {})
682 return next(e)
683 })
684
685 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
686 if (e.props.hasSurvey) return next(e)
687 const waiting = await offered($)
688 const snap = ((await $.state.get(isOn)).value ?? true) ? ((await $.state.get(snapshot)).value ?? null) : null
689 const hasBar = snap !== null && snap.segments.length > 0
690 if (!hasBar && !waiting) return next(e)
691
692 const { Box, Text, Button } = $.ui.resolve(e)
693 const w = await wordsIn($)
694 const at = (await $.state.get(now)).value ?? 0
695 // The buttons put the command in the prompt box rather than run it: a handoff wants to be
696 // told what the next session is for, and either can still be thought better of.
697 const fill = (text: string) => void $.prompt.fill({ text }).catch(() => {})
698 // A handoff left by an earlier conversation in this folder, to pick up from; shown whether
699 // or not the bar is on.
700 const resume = waiting ? (
701 <Box flexDirection="row" gap={1}>
702 <Box flexShrink={1}>
703 <Text wrap="truncate" color="suggestion" bold>
704 ↩ handoff {w.handoffAt(at - waiting.at < 60_000 ? undefined : span(at - waiting.at, w))}: {waiting.title}
705 </Text>
706 </Box>
707 <Button key="resume" label={w.resume} hotkey="r" plain onPress={() => fill(resumeText(waiting.path, w))} />
708 <Button key="dismiss" label={w.dismiss} hotkey="x" plain dimColor onPress={() => void forgetHandoff($, waiting)} />
709 </Box>
710 ) : null
711 if (!hasBar)
712 return (
713 <Box flexDirection="column">
714 {resume}
715 {await next(e)}
716 </Box>
717 )
718
719 const thresholds = (await $.state.get(zoneState)).value ?? kept.zone
720 const zone = zoneOf(snap.percent, thresholds)
721 const figure = ` ${snap.percent}%`
722 const pace = (await $.state.get(paceState)).value ?? null
723 const turns = snap.compactIn !== undefined ? turnsLeft(snap.compactIn, pace) : undefined
724 // The widest layout that fits the band, so the two sides never meet whatever the terminal.
725 const { context, limits, width } = layoutOf(snap, turns, at, e.props.bodyColumns, w)
726 const cells = allocate(snap.segments, snap.max, width)
727 const left = (
728 <Text wrap="truncate">
729 {snap.segments.map((s, i) =>
730 cells[i]! > 0 ? (
731 <Text color={s.color} dimColor={s.kind === 'free'}>
732 {GLYPH[s.kind].repeat(cells[i]!)}
733 </Text>
734 ) : null,
735 )}
736 <Text color={ZONE_COLOR[zone]} bold={zone === 'over'}>
737 {figure}
738 </Text>
739 <Text dimColor>{context}</Text>
740 </Text>
741 )
742 const right =
743 limits.length === 0 ? null : (
744 <Text wrap="truncate">
745 {limits.map((l, i) => (
746 <Text>
747 <Text dimColor>{`${i > 0 ? ' · ' : ''}${l.label} `}</Text>
748 <Text color={limitColor(l.percent)} bold={l.percent >= LIMIT_ALERT_AT}>
749 {l.percent}%
750 </Text>
751 <Text dimColor>{l.reset}</Text>
752 </Text>
753 ))}
754 </Text>
755 )
756 // The window on the left, the account's limits pinned to the right end.
757 const bar = (
758 <Box flexDirection="row" justifyContent="space-between" gap={GAP}>
759 {left}
760 {right}
761 </Box>
762 )
763 // Past the zone: what to do about it.
764 const over =
765 zone === 'over' ? (
766 <Box flexDirection="row" gap={1}>
767 <Text color="error">{w.pastZone(thresholds)}</Text>
768 {handoff ? <Button key="handoff" label="handoff" hotkey="h" plain onPress={() => fill(`/${handoff} `)} /> : null}
769 <Button key="compact" label="/compact" hotkey="c" plain onPress={() => fill('/compact ')} />
770 </Box>
771 ) : null
772 // Yellow or red: what fills the window, in the bar's colours, so the cure is plain.
773 const top =
774 zone === 'ok' ? null : (
775 <Text wrap="truncate">
776 <Text dimColor>{w.largest} </Text>
777 {largest(snap.segments).map((s, i) => (
778 <Text>
779 {i > 0 ? <Text dimColor> · </Text> : null}
780 <Text color={s.color}>■</Text> {w.category(s.name)} <Text color={ZONE_COLOR[zone]}>{tiny(s.tokens)}</Text>
781 </Text>
782 ))}
783 </Text>
784 )
785 // Idle past the prompt cache's life: the next reply sends the whole window again uncached, and
786 // a handoff into a fresh conversation may cost less.
787 const replied = (await $.state.get(repliedAt)).value ?? 0
788 const cold =
789 replied > 0 && at - replied >= cacheTtlOf(snap.limits) ? (
790 <Box flexDirection="row" gap={1}>
791 <Box flexShrink={1}>
792 <Text wrap="truncate" color="warning">
793 {w.cold(span(at - replied, w), tiny(snap.total))}
794 </Text>
795 </Box>
796 {handoff && !over ? <Button key="cold-handoff" label="handoff" hotkey="h" plain onPress={() => fill(`/${handoff} `)} /> : null}
797 </Box>
798 ) : null
799 // The band is shared: what the plugins beneath draw there stays, under the bar.
800 const below = await next(e)
801 const rows = [over, top, cold].filter(r => r !== null).slice(0, Math.max(0, e.props.maxRows - 1))
802 return (
803 <Box flexDirection="column">
804 {bar}
805 {rows}
806 {resume}
807 {below}
808 </Box>
809 )
810 })
811
812 on('ui.render', { component: 'Pane', requestId: ZONE_PANE }, async ($, e) => {
813 const { Box, Text, Button } = $.ui.resolve(e)
814 const w = await wordsIn($)
815 const t = (await $.state.get(zoneState)).value ?? kept.zone
816 const snap = (await $.state.get(snapshot)).value ?? null
817 const step = (which: keyof Thresholds, label: string, color: string) => (
818 <Box flexDirection="row" gap={1}>
819 <Text color={color}>{label}</Text>
820 <Button key={`${which}-down`} label="−" onPress={() => void setZone($, nudge(t, which, -ZONE_STEP))} />
821 <Text bold>{String(t[which]).padStart(3)}%</Text>
822 <Button key={`${which}-up`} label="+" onPress={() => void setZone($, nudge(t, which, ZONE_STEP))} />
823 </Box>
824 )
825 const zone = snap ? zoneOf(snap.percent, t) : undefined
826 return (
827 <Box flexDirection="column">
828 {step('warnAt', w.yellowFrom, 'warning')}
829 {step('alertAt', w.redFrom, 'error')}
830 <Box flexDirection="row" gap={1} marginTop={1}>
831 <Text dimColor>{w.presets}</Text>
832 {PRESETS.map((p, i) => (
833 <Button
834 key={`preset-${i + 1}`}
835 label={`${p.warnAt}–${p.alertAt}`}
836 hotkey={String(i + 1)}
837 variant={p.warnAt === t.warnAt && p.alertAt === t.alertAt ? 'primary' : 'secondary'}
838 onPress={() => void setZone($, p)}
839 />
840 ))}
841 </Box>
842 {snap && zone ? (
843 <Text>
844 <Text dimColor>{w.nowLabel}</Text>
845 <Text color={ZONE_COLOR[zone]} bold>
846 {snap.percent}%
847 </Text>
848 <Text dimColor>{w.zoneNow[zone]}</Text>
849 </Text>
850 ) : null}
851 <Box flexDirection="row" gap={1} marginTop={1}>
852 <Button key="reset" label={w.resetTo(DEFAULT_THRESHOLDS)} hotkey="r" dimColor onPress={() => void setZone($, 'reset')} />
853 <Text dimColor>{w.savedHint}</Text>
854 </Box>
855 </Box>
856 )
857 })
858
859 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
860 const { Box, Text, Button } = $.ui.resolve(e)
861 const w = await wordsIn($)
862 const d = (await $.state.get(details)).value ?? null
863 const refreshButton = <Button key="refresh" label={w.refresh} hotkey="r" dimColor onPress={() => refreshDetails($).catch(() => {})} />
864 if (!d)
865 return (
866 <Box flexDirection="column">
867 <Text dimColor>{w.counting}</Text>
868 {refreshButton}
869 </Box>
870 )
871
872 const pct = (n: number) => (d.max > 0 ? `${((n / d.max) * 100).toFixed(1)}%` : '')
873 const line = (r: Row, color?: string) => (
874 <Box flexDirection="row">
875 <Box flexGrow={1}>
876 <Text wrap="truncate">
877 {color ? <Text color={color}>■ </Text> : ' '}
878 {r.name}
879 {r.note ? <Text dimColor> {r.note}</Text> : null}
880 </Text>
881 </Box>
882 <Text>
883 {' '}
884 {short(r.tokens).padStart(6)} <Text dimColor>{pct(r.tokens).padStart(6)}</Text>
885 </Text>
886 </Box>
887 )
888 const section = (title: string, rows: Row[], extra?: string) =>
889 rows.length === 0 ? null : (
890 <Box flexDirection="column" marginTop={1}>
891 <Text bold>
892 {title}
893 {extra ? <Text dimColor> {extra}</Text> : null}
894 </Text>
895 {rows.map(r => line(r))}
896 </Box>
897 )
898
899 return (
900 <Box flexDirection="column">
901 <Text>
902 <Text bold>{d.model}</Text>
903 <Text dimColor>
904 {' '}
905 {short(d.total)}/{short(d.max)} ({pct(d.total)})
906 </Text>
907 </Text>
908 <Box flexDirection="column" marginTop={1}>
909 <Text bold>{w.categories}</Text>
910 {d.categories.map(c => line({ name: w.category(c.name), tokens: c.tokens, note: c.kind === 'deferred' ? w.onDemand : undefined }, c.color))}
911 </Box>
912 {section(w.memoryFiles, d.memoryFiles)}
913 {section(w.mcpServers, d.mcpServers)}
914 {d.skills ? section(w.skills, d.skills.top, w.skillsNote(d.skills.included, d.skills.total, short(d.skills.tokens))) : null}
915 {section(w.customAgents, d.agents)}
916 {d.commands ? (
917 <Box flexDirection="column" marginTop={1}>
918 <Text bold>{w.slashCommands}</Text>
919 <Text dimColor>
920 {' '}
921 {w.commandsNote(d.commands.included, d.commands.total, short(d.commands.tokens))}
922 </Text>
923 </Box>
924 ) : null}
925 {d.api ? (
926 <Box flexDirection="column" marginTop={1}>
927 <Text bold>{w.lastCall}</Text>
928 <Text dimColor>
929 {' '}
930 {w.apiLine(short(d.api.cacheRead), short(d.api.cacheWrite), short(d.api.uncached), short(d.api.output))}
931 </Text>
932 </Box>
933 ) : null}
934 <Box marginTop={1}>{refreshButton}</Box>
935 </Box>
936 )
937 })
938}
939types/index.d.ts 46 lines1export type Segment = { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: number }
3export type Snapshot = {
4 segments: Segment[]
5 total: number
6 max: number
7 percent: number
8 startedAt: number
9 limits: Limit[]
10 // Tokens left before auto-compaction runs; absent when it is off.
11 compactIn?: number
12}
13
14export type Row = { name: string; tokens: number; note?: string }
15export type Details = {
16 model: string
17 max: number
18 total: number
19 categories: { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
20 memoryFiles: Row[]
21 mcpServers: Row[]
22 agents: Row[]
23 skills?: { total: number; included: number; tokens: number; top: Row[] }
24 commands?: { total: number; included: number; tokens: number }
25 api: { uncached: number; cacheRead: number; cacheWrite: number; output: number } | null
26}
27
28// The working zone: from `warnAt` % of the window the figure turns yellow, from `alertAt` red.
29export type Thresholds = { warnAt: number; alertAt: number }
30
31// A handoff document written in a session, offered to the next one in the same folder.
32export type Handoff = { path: string; cwd: string; at: number; title: string }
33
34// How the window grew over the last turns of the main loop: the tokens at the latest turn's end
35// and what each counted turn added.
36export type Pace = { lastTotal: number; deltas: number[] }
37
38// The language the mod speaks: /context-bar lang picks it and the store keeps it.
39export type Lang = 'en' | 'ru'
40
41declare module 'claude-code' {
42 interface PluginState {
43 'context-bar-compact': { isOn: boolean; snapshot: Snapshot | null; now: number; details: Details | null; zone: Thresholds; handoff: Handoff | null; hasStarted: boolean; pace: Pace | null; repliedAt: number; lang: Lang }
44 }
45}
46