SLOPSHOPPER

context-bar-compact

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…

panebandguardcommandtoastprompt
v0.6.2MITupdated 2026-10-19Darvl-52/context-bar-compact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar-compact
│ ┃ context-zone ✕ › fix the failing auth test and add an audit log call │ ┃ Yellow from [ − ] 50% [ + ] │ ┃ Red from [ − ] 60% [ + ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Presets [ 40–50 ] [ 50–60 ] [ 60–70 ] [ 70–8 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Reset to 50–60 ] Saved for every session · ⏺ 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 │ │ › /context-bar │ ⎿ context-bar-compact: Context bar off. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-zone
Yellow from [ − ] 50% [ + ] Red from [ − ] 60% [ + ] Presets [ 40–50 ] [ 50–60 ] [ 60–70 ] [ 70–80 ] [ Reset to 50–60 ] Saved for every session · Esc closes
Pane · context-details
Counting tokens… [ Refresh ]
README

context-bar-compact

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

  • The bar, 20 cells: █ 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.

The working zone

Answers get worse well before the window is full, so the bar watches the share in use:

  • under 50% the figure is green;
  • from 50% it turns yellow, and a row names the three categories that fill the window most, each with the colour it has on the bar, so you see whether a compact, a handoff or turning off an MCP server would help;
  • from 60% it turns red, a toast says so once, and another row offers a way out:
█████████████░░░▒▒▒▒ 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%.

A cold cache

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.

In Russian

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

Picking up a handoff

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.

Commands

CommandWhat it does
/context-barHide or show the bar for this session.
/context-bar detailsOpen 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 zoneOpen the working-zone pane: −/+ for yellow and red, presets 1–4, r resets.
/context-bar zone 40 70Yellow from 40%, red from 70%, without the pane.
/context-bar zone resetBack to 50–60.
/context-bar lang ruSpeak 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):

KeyWhat it does
hPut /mattpocock-skills:handoff in the prompt box (when installed), from the red row or the cold-cache row.
cPut /compact in the prompt box.
rPut the request to read the offered handoff in the prompt box.
xDismiss 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.

Install

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.

Credit

A compact fork of context-bar by Akash Bhatnagar (MIT), which draws the same data over three lines.

Source 2 files
hooks/register.tsx 939 lines
1import 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}
939
types/index.d.ts 46 lines
1export 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