Status band above the prompt: context weather, usage limits, and the Ponytail and Caveman levels

A Claude Code mod that draws a status band above the prompt: a live "weather forecast" of the context window, how much of your usage limits is spent, and the level Ponytail and Caveman are running at.

Once the context passes 75% (and Claude isn't mid-turn), a Compact button appears in the band.
Where the widgets don't fit on one row, they wrap whole onto a second, while Compact and ⚙ stay at the right of the first.
Prefer it out of the way? View in the ⚙ Config box (or /overalls view …) puts Overalls in a Pane instead: the same widgets, wrapping to its width, with the MCP errors, tool denials and running agents listed in full, and the Config box inside it. both shows the band and the Pane.
In the Claude Code terminal:
/plugin marketplace add Troepster/overalls
/plugin install overalls@overalls
/reload-plugins
Or from any shell, which also covers the desktop app (new sessions pick it up):
claude plugin marketplace add Troepster/overalls
claude plugin install overalls@overalls
If the band doesn't appear, start a new session or restart Claude Code.
To update: /plugin marketplace update overalls in Claude Code, or from a shell:
claude plugin marketplace update overalls
claude plugin update overalls@overalls
Ponytail and Caveman are optional. Without Caveman its widget doesn't show; without Ponytail its widget reads not installed until you set it to off. Either way, the ⚙ Config box offers to install them.
A mod runs inside Claude Code with the same access Claude Code has, so read hooks/register.tsx before installing.
Each appears as a row in the Claude Code config menu under overalls; changing one reloads the mod. The /overalls command sets them from the prompt, which also works in the desktop app, where /config isn't available:
/overalls weather minimal
/overalls ponytail off
/overalls (shows the current values)

The ⚙ at the right of the band opens a Config box above it with every choice for every setting, the current one ticked; pressing one sets it, so you see the change in the band as you make it. Press ⚙ again to close it. Hover over a setting's name in the Config box for a line on what it is and the /overalls command that sets it.
Pressing the forecast itself (Showers, or 67% at minimal) steps the weather detail through minimal → normal → full and round again; off is set in the Config box.
Where the mod has a /config row (the terminal), /overalls sets that row. Where it has none (the desktop app), it keeps the choice in the mod's own store instead, and that choice wins over the /config value in every session until you set the same setting with /overalls in a session that has the row.
| Setting | Values | Default | What it does |
|---|---|---|---|
| View | bar · pane · both | bar | Where Overalls shows: the band above the prompt, a Pane with everything at full size (it opens by itself only in a wide enough window), or both |
| Weather detail | off · minimal · normal · full (typed; anything else counts as full) | full | How much of the forecast to show |
| Ponytail level | off · icon · text (typed; anything else counts as icon, and an old on/off value as icon/off) | icon | Show whether Ponytail is installed and its level, after its logo or the word ponytail: |
| Caveman mode | off · icon · text (typed; anything else counts as icon) | icon | Show Caveman's mode where it's installed, after 🪨 or the word caveman: |
| Offer megacave | on · off | off | List Caveman's Classical Chinese mode (megacave) in its dropdown; in the Config box it sits on Caveman's row |
| Usage limits | on · off | on | Show how much of your 5-hour and weekly limits is spent (subscriptions only) |
| Limit resets | on · off | on | Show how long until each limit resets (5h 42% ↻1h20m); in the Config box it sits on the Limits row |
| Limit run-out | off · warn · always (typed; anything else counts as warn) | warn | Warn when a limit will run out before it resets, at your pace over the last hour: ⚠1h40m in amber, red under 30 minutes. always also shows ✓ lasts for a limit that won't. Needs 10 minutes of samples and 2 points of movement first. Follows each limit, or stands alone (5h ⚠1h40m) with the limits off |
| Session cost | on · off | off | Show what this session has cost so far |
| MCP warnings | on · off | on | Warn when an MCP server or connector fails or needs signing in again |
| Running agents | on · off | on | Show ⧗ 2 agents while subagents run; pressing it lists them (type and task) |
| Turns left | on · off | on | Show ⌛ ~6 turns: about how many more turns fit, at the average growth of the last five that grew (red from 3) |
| Tool denials | on · off | on | Show ⛔ 2 denied for tool calls refused by you, a hook, a permission rule or the auto-mode classifier; pressing it lists them, with Dismiss |
What each weather level shows:
| Level | Band |
|---|---|
minimal | ☂ 67% |
normal | ☂ Showers 67% · 134.4k / 200k |
full | normal plus the last 12 turns as a sparkline and the change since last turn |
off | nothing (the Ponytail segment still shows if enabled) |
<img src="assets/ponytail-logo.png" width="110" alt="Ponytail, the lazy senior dev">
ponytail@… entry in enabledPlugins. Otherwise the band shows not installed beside the logo.$PONYTAIL_DEFAULT_MODE, else defaultMode in its config.json ($XDG_CONFIG_HOME/ponytail/, or ~/.config/ponytail/), else full/ponytail <level> (or /ponytail:ponytail <level>), /ponytail-review, stop ponytail and normal mode change it as they go in. Another session's switch doesn't change this band./ponytail <level> command.Where either isn't installed, its row in the ⚙ Config box has an Install button. A mod can't install plugins, so it types a request into the prompt box, for example:
Install the caveman Claude Code plugin: run `claude plugin marketplace add JuliusBrussee/caveman` then `claude plugin install caveman@caveman`.
Send it and Claude runs the two commands, each behind the usual permission prompt. A plugin's hooks start with the next session.
caveman@… entry in enabledPlugins; otherwise nothing shows..caveman-sessions/<session id>.mode in your Claude config directory, so the band reads that. A session with no file of its own (one started before Caveman was installed) shows off; the shared .caveman-active, which holds the last mode set in any window, is read only when there's no session id./megacave, /ultracave, /caveman and /caveman off commands.Claude Code gives a mod no list of MCP servers or their state, so the band learns of trouble from Claude's own tool calls:
mcp__<server>__… call that fails to sign in or connect (a bad-arguments error doesn't count), andThe band then shows ⚠ 2 MCP errors. Pressing it opens an MCP errors box above the band (like the Config box; one is open at a time) with a row per server: its name, sign in or failed, the error as Claude saw it, and:
claude://claude.ai/customize/connectors), and at https://claude.ai/customize/connectors from the CLI or the web. For any other server, /mcp in the terminal; the desktop app's /mcp only lists servers, so there it says to sign in from a terminal session.A server leaves the list on its next successful call, or when dismissed. It's caught on its first failure, not before.
Overalls makes no network requests and sends nothing anywhere. Everything it does stays inside your Claude Code session:
$.session.usage()) after each turn$.session.usage()) when those widgets are on, and the clock to count down to each reset$.agent.list(): type, task, status) when the agents widget is on$.session.usage()), keeping the last hour of them in session memory to tell when a limit will run out.caveman-sessions/<session id>.mode (or .caveman-active when there's no session id)config.json, for its default level, and the PONYTAIL_DEFAULT_MODE, XDG_CONFIG_HOME, CLAUDE_CONFIG_DIR and HOME environment variables/ponytail …, stop ponytail, normal mode), keeping nothing of it but the level/ponytail <level>, or Caveman's command for the mode, as if you had typed it (which Claude reads, as it would your typing), or types it into the prompt box if it can't run it/mcp only when you press Reconnect in the terminalCLAUDE_CODE_ENTRYPOINT while the MCP errors box is open, to tell the desktop app from the CLI and the web; in the desktop app, pressing Reconnect on a connector hands claude://claude.ai/customize/connectors to the system's opener (open on macOS, xdg-open on Linux, cmd /c start on Windows), which opens that page in the app/compact for you, or types it into the prompt box if it can't run it/overalls weather … or /overalls ponytail … (and the rest), pick one in the ⚙ Config box, or press the forecast to step the weather detail: through the /config row where there is one (saved in your Claude Code settings, as /config saves them), otherwise in the mod's own store, a small JSON file Claude Code keeps for the plugin in your Claude config directory.Apart from that store, it writes no files, and the only program it starts is that system opener, with that one fixed address, when you press Reconnect.
It hooks four events only to observe them, never changing what they carry: tool.call (note an MCP server failing or answering again, and a refused call), turn.complete (take a reading), session.compact (record the drop after a compaction, and reset the Ponytail level to its default) and prompt.submit (note a Ponytail switch once your prompt has gone in, so /ponytail lite shows straight away). It also draws the band above the prompt.
Load your clone directly instead of the installed copy, so saves hot-reload:
claude --plugin-dir ~/Projects/overalls
or for every session (including the desktop app), in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/overalls" } }
Don't also have the marketplace copy installed, or you'll get two bands.
claude plugin validate .
claude plugin test .
hooks/register.tsx is the module; types/index.d.ts declares the state it keeps. To add a setting, declare a field under userConfig in .claude-plugin/plugin.json and read it from options in register.
Ponytail and its logo are by Dietrich Gebert, used under the MIT licence (assets/PONYTAIL-LICENSE). Overalls only reads the level Ponytail writes; it isn't affiliated with or endorsed by the Ponytail project.
Caveman is by Julius Brussee; "Caveman" is his trademark. Overalls only reads the mode Caveman writes and runs its commands, uses none of its logos (🪨 is a stock emoji), and isn't affiliated with or endorsed by the Caveman project.
hooks/register.tsx 935 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Denial, McpTrouble, Reading, Sample } from '../types'
5import { PONYTAIL_PNG } from './ponytail-icon'
6
7const history = atom({ plugin: 'overalls', key: 'history' } as const, [] as Reading[])
8const ponytail = atom({ plugin: 'overalls', key: 'ponytail' } as const, '')
9const level = atom({ plugin: 'overalls', key: 'level' } as const, '')
10const caveman = atom({ plugin: 'overalls', key: 'caveman' } as const, '')
11const mcpDown = atom({ plugin: 'overalls', key: 'mcpDown' } as const, {} as Record<string, McpTrouble>)
12// Which box is open above the band, if any: ⚙'s Config box or the MCP errors box. One at a time.
13const panel = atom({ plugin: 'overalls', key: 'panel' } as const, '' as '' | 'config' | 'mcp' | 'agents' | 'denials')
14const denials = atom({ plugin: 'overalls', key: 'denials' } as const, [] as Denial[])
15// Each usage limit's percent over the last hour, by kind, for when it runs out.
16const limitSamples = atom({ plugin: 'overalls', key: 'limitSamples' } as const, {} as Record<string, Sample[]>)
17
18const BARS = '▁▂▃▄▅▆▇█'
19const DETAILS = ['off', 'minimal', 'normal', 'full'] as const
20const PONY = ['off', 'icon', 'text'] as const
21const ONOFF = ['on', 'off'] as const
22const RUNOUT = ['off', 'warn', 'always'] as const
23// Every setting and what it takes, in /overalls, the Config box and userConfig alike.
24const VIEWS = ['bar', 'pane', 'both'] as const
25const FIELDS = { weather: DETAILS, ponytail: PONY, caveman: PONY, megacave: ONOFF, limits: ONOFF, resets: ONOFF, cost: ONOFF, mcp: ONOFF, agents: ONOFF, turns: ONOFF, denials: ONOFF, runout: RUNOUT, view: VIEWS } as const
26type Field = keyof typeof FIELDS
27const COMPACT_AT = 75
28// The Config box's hover cards, keyed by the hover scope that reveals them. The band has none:
29// a card can't leave the band, so there it covers the forecast it would explain.
30const TIPS = {
31 'cfg-weather': 'Weather: a forecast of how full the context window is',
32 'cfg-ponytail': 'Ponytail: the level the Ponytail plugin is running at',
33 'cfg-caveman': 'Caveman: the mode the Caveman plugin is running in',
34 'cfg-megacave': 'Megacave: offer Caveman\'s Classical Chinese mode in its dropdown',
35 'cfg-limits': 'Limits: how much of your 5-hour and weekly usage is spent, and when each resets',
36 'cfg-resets': 'Resets: how long until each limit resets',
37 'cfg-runout': 'Run-out: when a limit runs out before it resets, at your pace over the last hour; always also marks one that lasts',
38 'cfg-agents': 'Agents: how many subagents are running; press it to see them',
39 'cfg-view': 'View: the band above the prompt, a Pane with everything at full size, or both',
40 'cfg-turns': 'Turns: about how many more turns fit, at the pace of the last few',
41 'cfg-denials': 'Denials: tool calls that were refused (by you, a hook or a permission rule); press it to see them',
42 'cfg-cost': 'Cost: what this session has cost so far',
43 'cfg-mcp': 'MCP: a warning when an MCP server or connector fails or needs signing in again',
44} as const
45// Where Overalls shows: the band above the prompt, a Pane with everything at full size, or both.
46async function applyView($: EngineInterface, view: string) {
47 if (view === 'bar') await $.ui.close({ id: 'overalls' }).catch(() => {})
48 else await $.ui.open({ id: 'overalls', title: 'Overalls' }).catch(() => {})
49}
50const USAGE = Object.entries(FIELDS).map(([f, vs]) => `${f} ${vs.join('|')}`).join(' · ')
51
52export const ponytailLabel = (installed: boolean, level: string | undefined) =>
53 !installed ? 'not installed' : level?.trim() || 'off'
54
55// Ponytail keeps one level file for every session (<claude dir>/.ponytail-active), so it can't say
56// what this session runs at. The level is tracked here per session instead, by Ponytail's own
57// rules (hooks/ponytail-activate.js, ponytail-mode-tracker.js, ponytail-config.js in 4.9.0): a
58// session, and a compaction, start at its default; a submitted command changes it.
59const MODES = ['off', 'lite', 'full', 'ultra']
60
61// What a submitted prompt does to the session's level: a level, 'default', or undefined (none).
62export const levelAfter = (text: string) => {
63 const t = text.trim().toLowerCase()
64 const [first = '', arg = ''] = t.split(/\s+/)
65 const cmd = first.replace(/^[@$]/, '/')
66 if (cmd === '/ponytail-review' || cmd === '/ponytail:ponytail-review') return 'review'
67 if (cmd === '/ponytail' || cmd === '/ponytail:ponytail') {
68 // A bare /ponytail only reports; `/ponytail default x` sets new sessions', not this one's.
69 if (arg === '' || arg === 'default') return undefined
70 return MODES.includes(arg) ? arg : 'default'
71 }
72 const bare = t.replace(/[.!?\s]+$/, '')
73 return bare === 'stop ponytail' || bare === 'normal mode' ? 'off' : undefined
74}
75
76// PONYTAIL_DEFAULT_MODE, else defaultMode in its config.json, else full.
77async function ponytailDefault($: EngineInterface) {
78 const env = String((await $.env.get('PONYTAIL_DEFAULT_MODE')) ?? '').toLowerCase()
79 if (MODES.includes(env)) return env
80 const xdg = await $.env.get('XDG_CONFIG_HOME')
81 const dir = xdg ? `${xdg}/ponytail` : `${await $.env.get('HOME')}/.config/ponytail`
82 const raw = await $.fs.read(`${dir}/config.json`).catch(() => undefined)
83 try {
84 const mode = String(JSON.parse(String(raw)).defaultMode).toLowerCase()
85 if (MODES.includes(mode)) return mode
86 } catch {}
87 return 'full'
88}
89
90async function isInstalled($: EngineInterface, plugin: string) {
91 const plugins = ((await $.settings.read()).enabledPlugins ?? {}) as Record<string, unknown>
92 return Object.entries(plugins).some(([k, v]) => k.startsWith(`${plugin}@`) && v === true)
93}
94
95const claudeDir = async ($: EngineInterface) =>
96 (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
97
98// Never throws: a failed check leaves the last label rather than breaking the hook it rides on.
99const readPonytail = ($: EngineInterface) => checkPonytail($).catch(() => {})
100
101async function checkPonytail($: EngineInterface) {
102 const installed = await isInstalled($, 'ponytail')
103 let now = await read($, level)
104 if (installed && !now) {
105 now = await ponytailDefault($)
106 await update($, level, () => now)
107 }
108 await update($, ponytail, () => ponytailLabel(installed, now))
109}
110
111// Ponytail and Caveman switch only on a submitted command: their prompt hooks tell Claude. So a
112// pick runs it as if typed (a turn, as typing it is), under the plugin's name if the bare one is
113// refused, else leaves it in the box to send. True when it ran.
114async function runAsTyped($: EngineInterface, plugin: string, command: string, args = '') {
115 for (const name of [command, `${plugin}:${command}`]) {
116 if (await $.command.run({ command: name, args }).then(() => true, () => false)) return true
117 }
118 const text = `/${command}${args && ` ${args}`}`
119 const r = await $.prompt.fill({ text })
120 if (!r.isFilled) $.ui.toast(`Couldn't run it here: run ${text}`)
121 return false
122}
123
124// Where each plugin installs from. A mod can't install one, so Install leaves a request in the
125// prompt box for Claude to run (behind the usual permission prompts) once it's sent.
126const SOURCES = { ponytail: 'DietrichGebert/ponytail', caveman: 'JuliusBrussee/caveman' } as const
127async function askToInstall($: EngineInterface, plugin: keyof typeof SOURCES) {
128 const text = `Install the ${plugin} Claude Code plugin: run \`claude plugin marketplace add ${SOURCES[plugin]}\` then \`claude plugin install ${plugin}@${plugin}\`.`
129 const r = await $.prompt.fill({ text })
130 if (!r.isFilled) $.ui.toast(`Couldn't fill the prompt: install ${plugin} from ${SOURCES[plugin]}`)
131}
132
133const LEVELS = ['ultra', 'full', 'lite', 'off'] // strongest first, as the dropdown lists them
134async function switchPonytail($: EngineInterface, args: string) {
135 if (!(await runAsTyped($, 'ponytail', 'ponytail', args))) return
136 await update($, level, () => args)
137 await readPonytail($)
138}
139
140// Caveman keeps each session's mode in <claude dir>/.caveman-sessions/<session id>.mode, so this
141// session's is read straight from there. (It no longer reports a savings figure: 3.1.0 empties
142// .caveman-statusline-suffix as "not measurements".)
143// No file means Caveman isn't running here (a session started before it was installed): off. Its
144// own statusline falls back to the last-write-wins .caveman-active, which shows another window's
145// mode, so that is read only when there is no usable session id.
146// Older level names read as Caveman reads them (src/hooks/caveman-statusline.sh, 3.1.0).
147const CAVE_MODES = ['megacave', 'ultracave', 'caveman', 'off'] // strongest first
148export const caveMode = (raw: unknown) => {
149 const m = String(raw ?? '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '')
150 return ['caveman', 'lite', 'full'].includes(m) ? 'caveman'
151 : ['ultracave', 'ultra'].includes(m) ? 'ultracave'
152 : m === 'megacave' || m.startsWith('wenyan') ? 'megacave'
153 : ['commit', 'review', 'compress'].includes(m) ? m
154 : 'off'
155}
156
157const readCaveman = ($: EngineInterface) => checkCaveman($).catch(() => {})
158
159async function checkCaveman($: EngineInterface) {
160 if (!(await isInstalled($, 'caveman'))) return update($, caveman, () => '')
161 const dir = await claudeDir($)
162 const id = await $.session.id()
163 const own = /^[A-Za-z0-9_-]{1,128}$/.test(id) ? `${dir}/.caveman-sessions/${id}.mode` : `${dir}/.caveman-active`
164 const raw = await $.fs.read(own).catch(() => undefined)
165 await update($, caveman, () => caveMode(raw))
166}
167
168// Caveman's modes are commands of their own; off is `/caveman off`.
169async function switchCaveman($: EngineInterface, mode: string) {
170 if (await (mode === 'off' ? runAsTyped($, 'caveman', 'caveman', 'off') : runAsTyped($, 'caveman', mode))) {
171 await readCaveman($)
172 }
173}
174
175// No API lists MCP servers or their state, so the band learns of trouble from Claude's own tool
176// calls: an `mcp__<server>__*` call that fails to sign in or connect, or the servers a ToolSearch
177// reports as failed. A server's next successful call clears it.
178export const mcpServer = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__')[1] ?? '' : '')
179export const mcpTrouble = (text: string) =>
180 /unauthori[sz]ed|\b40[13]\b|auth(entication|orization)? (required|failed|expired)|sign(ed)? ?in|log(ged)? ?in|re-?auth|oauth|token (has )?(expired|invalid|revoked)|invalid[_ ]token/i.test(text) ? 'sign in'
181 : /not connected|disconnected|failed to connect|connection (closed|refused|reset|lost)|econn|etimedout|timed out|server (is )?unavailable|no such server|not found: server/i.test(text) ? 'failed'
182 : ''
183// A claude.ai connector is named claude_ai_<Name>, or by its id; it's signed in from claude.ai.
184export const isConnector = (server: string) => /^claude_ai_/.test(server) || /^[0-9a-f]{8}-/.test(server)
185// Customize › Connectors, as Claude's support article links it (support.claude.com/en/articles/11176164);
186// the old /settings/connectors only says they moved.
187export const CONNECTORS_URL = 'https://claude.ai/customize/connectors'
188// The desktop app (CLAUDE_CODE_ENTRYPOINT=claude-desktop) opens the same page in itself from its
189// claude:// scheme (claude://claude.ai/<path>), which a Link can't carry (https only), so there it
190// goes to the system's opener; the CLI and the web get the https link, the web's own site.
191const CONNECTORS_APP_URL = 'claude://claude.ai/customize/connectors'
192const OPENERS = [['open'], ['xdg-open'], ['cmd', '/c', 'start', '']] // macOS, Linux, Windows
193async function openConnectors($: EngineInterface) {
194 for (const opener of OPENERS) {
195 const r = await $.process.run([...opener, CONNECTORS_APP_URL]).catch(() => undefined)
196 if (r?.exitCode === 0) return
197 }
198 $.ui.toast(`Sign the connector back in at ${CONNECTORS_URL}`)
199}
200
201// claude_ai_Gmail → Gmail, plugin_productivity_atlassian → atlassian; a connector id is cut short.
202export const mcpLabel = (server: string) => {
203 const name = server.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '').replace(/_/g, ' ')
204 return /^[0-9a-f]{8}-/.test(name) ? `connector ${name.slice(0, 8)}` : name
205}
206
207// Any other server signs in through /mcp's menu, which the terminal has; the desktop app's /mcp
208// only prints a count. The entry stays until the server answers or is dismissed.
209async function reconnectMcp($: EngineInterface, surface: string, server: string) {
210 if (surface !== 'terminal') {
211 $.ui.toast(`Sign ${mcpLabel(server)} in with /mcp in a terminal session (claude): the desktop app's /mcp only lists servers`)
212 return
213 }
214 const ran = await $.command.run({ command: 'mcp' }).then(() => true, () => false)
215 if (!ran) $.ui.toast(`Reconnect ${mcpLabel(server)} with /mcp`)
216}
217
218const dismissMcp = ($: EngineInterface, server?: string) =>
219 update($, mcpDown, prev => {
220 if (server === undefined) return {}
221 const { [server]: _, ...rest } = prev
222 return rest
223 })
224
225// The error as Claude saw it, on one line and cut short.
226const detailOf = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 100)
227
228// How long until a limit resets, at the two largest units: 1h20m, 3d4h, 12m; now or past, 0m.
229export const untilReset = (resetsAt: string, now: number) => {
230 const m = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
231 const [d, h, min] = [Math.floor(m / 1440), Math.floor((m % 1440) / 60), m % 60]
232 return d ? `${d}d${h ? `${h}h` : ''}` : h ? `${h}h${min ? `${min}m` : ''}` : `${min}m`
233}
234
235// A limit's samples with this one added: the last hour, at most one per 25 s (the band redraws every
236// 30 s, and on more than the clock). A fall in percent is a reset, which starts them over.
237export const sampled = (prev: Sample[], at: number, pct: number) => {
238 const last = prev.at(-1)
239 if (last && pct < last.pct) return [{ at, pct }]
240 if (last && at - last.at < 25_000) return prev
241 return [...prev.filter(s => at - s.at <= 3_600_000), { at, pct }]
242}
243
244// When a limit runs out at its pace over the samples: a time, or Infinity when it lasts until it
245// resets (or hasn't moved). Undefined under 10 minutes, or 1 point of movement: whole-percent steps
246// are noise until then.
247export const runsOutAt = (samples: Sample[], resetsAt?: string) => {
248 const [first, last] = [samples[0], samples.at(-1)]
249 if (!first || !last || last.at - first.at < 600_000) return undefined
250 const moved = last.pct - first.pct
251 if (moved <= 0) return Infinity
252 if (moved < 2) return undefined
253 const at = last.at + ((100 - last.pct) * (last.at - first.at)) / moved
254 return resetsAt && at >= Date.parse(resetsAt) ? Infinity : at
255}
256
257// About how many more turns fit: the space left over the average growth of the last 5 turns that
258// grew (a compaction's drop isn't a turn's growth). Undefined until 3 readings, or with no growth.
259export const turnsLeft = (readings: Reading[]) => {
260 const growth = readings.slice(1).map((r, i) => r.tokens - readings[i]!.tokens).filter(d => d > 0).slice(-5)
261 const now = readings.at(-1)
262 if (readings.length < 3 || !growth.length || !now) return undefined
263 return Math.max(0, Math.floor((now.window - now.tokens) / (growth.reduce((a, b) => a + b, 0) / growth.length)))
264}
265
266// A tool call refused rather than failed: denied by a hook (`deny`), declined by the person, or
267// refused by a permission rule or the auto-mode classifier. Its own error (a command that failed)
268// is not a denial.
269export const deniedBy = (text: string) =>
270 /permission for this action was denied|doesn't want to proceed|user (has )?(rejected|denied|declined)|was (denied|blocked) by|blocked by (a |the )?hook|not allowed by (your )?(permission|settings)|permission (rule|settings) (deny|denied)/i.test(text)
271
272export const weather = (percent: number) =>
273 percent < 25 ? { icon: '☀', word: 'Clear' }
274 : percent < 50 ? { icon: '☁', word: 'Cloudy' }
275 : percent < 75 ? { icon: '☂', word: 'Showers' }
276 : percent < 90 ? { icon: '☇', word: 'Storm' }
277 : { icon: '↯', word: 'Compact soon' }
278
279const hsl = (hue: number, l: number) => {
280 const f = (n: number) => {
281 const k = (n + hue / 30) % 12
282 return l - l * Math.max(-1, Math.min(k - 3, 9 - k, 1))
283 }
284 return [f(0), f(8), f(4)]
285}
286
287// WCAG relative luminance of an sRGB triple in 0..1.
288export const luminance = (rgb: number[]) => {
289 const [r = 0, g = 0, b = 0] = rgb.map(c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
290 return 0.2126 * r + 0.7152 * g + 0.0722 * b
291}
292
293export const contrast = (a: number[], b: number[]) => {
294 const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x)
295 return (hi + 0.05) / (lo + 0.05)
296}
297
298export const hexToRgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
299
300// Green at empty to red at full, every step held at one luminance (0.2) so it keeps
301// 3:1 against white, black and the usual dark greys alike: no theme detection needed.
302const TARGET = 0.2
303export const heat = (percent: number) => {
304 const hue = 120 * (1 - Math.min(100, Math.max(0, percent)) / 100)
305 let lo = 0
306 let hi = 0.5
307 for (let i = 0; i < 24; i++) {
308 const mid = (lo + hi) / 2
309 if (luminance(hsl(hue, mid)) < TARGET) lo = mid
310 else hi = mid
311 }
312 return '#' + hsl(hue, lo).map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
313}
314
315export const fmt = (n: number) =>
316 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : `${n}`
317
318const pct = (r: Reading) => (r.tokens / r.window) * 100
319
320// Bars scale to the window, so a full bar means a full context, not the busiest turn.
321export const bar = (r: Reading) => BARS[Math.min(7, Math.floor(pct(r) / 12.5))]
322
323// The same bars as an Svg: 4 px wide, 1 px apart, rising from the bottom of a 14 px line, at least
324// 1 px tall so an empty turn still shows.
325export const sparkSvg = (readings: Reading[]) =>
326 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${readings.length * 5} 14">` +
327 readings
328 .map((r, i) => {
329 const h = Math.max(1, Math.round((Math.min(100, pct(r)) / 100) * 14))
330 return `<rect x="${i * 5}" y="${14 - h}" width="4" height="${h}" rx="0.5" fill="${heat(pct(r))}"/>`
331 })
332 .join('') +
333 '</svg>'
334
335// The engine skips a plugin's own session.compact hook for a compaction that plugin
336// started, so the button calls this too.
337async function recordDrop($: EngineInterface, tokensAfter?: number) {
338 await update($, history, prev => {
339 const last = prev[prev.length - 1]
340 return last && tokensAfter !== undefined
341 ? [...prev, { tokens: tokensAfter, window: last.window }].slice(-12)
342 : []
343 })
344}
345
346async function sampleLimits($: EngineInterface) {
347 const limits = (await $.session.usage().catch(() => undefined))?.rateLimits
348 if (!limits?.length) return
349 const now = await $.clock.now().catch(() => Date.now())
350 await update($, limitSamples, prev => Object.fromEntries(limits.map(r => [r.kind, sampled(prev[r.kind] ?? [], now, r.percentUsed)])))
351}
352
353type Prefs = Partial<Record<Field, string | boolean>>
354type Options = Parameters<Register>[1]
355
356// Ponytail was an on/off switch before it had a choice of icon or text: on (true) is the icon.
357export const ponyMode = (v: unknown) =>
358 v === true ? 'icon' : v === false ? 'off' : PONY.find(m => m === String(v).trim().toLowerCase()) ?? 'icon'
359
360// /overalls choices made where the mod has no /config row (the desktop app) live in its
361// store and win over userConfig. A typed field, not a picker: the directory doesn't accept
362// userConfig `options` yet.
363async function prefs($: EngineInterface, options: Options) {
364 const saved = ((await $.store.get('prefs')) ?? {}) as Prefs
365 const pick = (f: Field) => String(saved[f] ?? options[f]).trim().toLowerCase()
366 return {
367 view: VIEWS.find(v => v === pick('view')) ?? 'bar',
368 detail: DETAILS.find(d => d === pick('weather')) ?? 'full',
369 pony: ponyMode(saved.ponytail ?? options.ponytail),
370 cave: ponyMode(saved.caveman ?? options.caveman),
371 megacave: pick('megacave') === 'on',
372 limits: pick('limits') !== 'off',
373 resets: pick('resets') !== 'off',
374 agents: pick('agents') !== 'off',
375 turns: pick('turns') !== 'off',
376 denials: pick('denials') !== 'off',
377 cost: pick('cost') === 'on',
378 mcp: pick('mcp') !== 'off',
379 runout: RUNOUT.find(m => m === pick('runout')) ?? 'warn',
380 }
381}
382
383async function refreshPonytail($: EngineInterface, options: Options) {
384 const p = await prefs($, options)
385 if (p.pony !== 'off') await readPonytail($)
386 if (p.cave !== 'off') await readCaveman($)
387}
388
389// Where the mod has a /config row (the terminal), set that and drop any stored choice;
390// the engine saves the row and reloads this module with it. Returns why it was refused, if it was.
391async function setPref($: EngineInterface, options: Options, field: string, value: string) {
392 const row = (await $.config.list()).find(
393 r => r.provider.plugin.split('@')[0] === 'overalls' && (r.key === field || r.key.endsWith(`.${field}`)),
394 )
395 const saved = ((await $.store.get('prefs')) ?? {}) as Record<string, unknown>
396 await $.store.set('prefs', row ? { ...saved, [field]: undefined } : { ...saved, [field]: value })
397 if (row) {
398 const r = await $.config.set({ key: row.key, value }).catch((err: unknown) => ({ deny: String(err) }))
399 return r.deny
400 }
401 await refreshPonytail($, options)
402 $.ui.invalidate('ui.render')
403}
404
405// Don't name a local `h`: JSX compiles to the global h().
406// One drawing for both places: the band above the prompt, and the Pane, where widgets flow the
407// same way under the ⚙ row and the MCP errors, denials and agents boxes are always open.
408// eslint-disable-next-line @typescript-eslint/no-explicit-any
409async function draw($: EngineInterface, e: any, next: (e: any) => Promise<any>, inPane: boolean, options: Options) {
410 const { view, detail, pony: ponyAs, cave: caveAs, megacave, limits, resets, cost, mcp, agents, turns, denials: showDenials, runout } = await prefs($, options)
411 const refused = showDenials ? await read($, denials) : []
412 const down = mcp ? Object.entries(await read($, mcpDown)) : []
413 const readings = detail === 'off' ? [] : await read($, history)
414 const pony = ponyAs !== 'off' ? await read($, ponytail) : ''
415 const cave = caveAs !== 'off' ? await read($, caveman) : ''
416 const usage = limits || cost || runout !== 'off' ? await $.session.usage().catch(() => undefined) : undefined
417 // With nothing else to show, ⚙ still draws alone, so both turned off can be turned on again.
418 if (!inPane && e.props.hasSurvey) return next(e)
419 const els = $.ui.resolve(e)
420 const { Box, Button, Link, Text } = els
421 // Widgets never shrink (the desktop app would wrap the text inside one); they wrap whole onto
422 // further rows of their own box, which takes the free width, so Compact and ⚙ hold the right
423 // end of the first row.
424 type El = ReturnType<typeof Box>
425 // In the Pane a widget sizes to its content, sharing rows with the others; if the Pane is
426 // narrower than one widget, its pieces keep their size and wrap between each other, so a
427 // button's label never breaks in two. (Full width put each widget on a row of its own.)
428 const widget = (...children: El[]) =>
429 inPane ? (
430 <Box flexDirection="row" flexWrap="wrap" flexShrink={1}>
431 {...children.map(c => <Box flexDirection="row" flexShrink={0}>{c}</Box>)}
432 </Box>
433 ) : (
434 <Box flexDirection="row" flexShrink={0}>{...children}</Box>
435 )
436 const band = (widgets: El[], ...end: El[]) =>
437 inPane ? (
438 <Box flexDirection="column" width="100%" gap={1}>
439 <Box flexDirection="row" gap={2}>
440 <Box flexDirection="row" flexGrow={1} />
441 {...end}
442 {settings}
443 </Box>
444 {...(open ? [configBox] : [])}
445 <Box flexDirection="row" flexWrap="wrap" width="100%" columnGap={2} rowGap={1}>
446 {...widgets}
447 </Box>
448 {...(down.length ? [mcpBox] : [])}
449 {...(refused.length ? [denialsBox] : [])}
450 {...(running.length ? [agentsBox] : [])}
451 </Box>
452 ) : withPanel(
453 <Box flexDirection="row" width="100%" alignItems="flex-start" gap={2}>
454 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1} columnGap={2} rowGap={1}>
455 {...widgets}
456 </Box>
457 {...end}
458 {settings}
459 </Box>,
460 )
461 // The Ponytail logo stands for the word: an Svg on the remote surfaces, an Image on the
462 // terminal, which draws the word dim in its place where it can't show pictures.
463 // A cell between a plugin's icon (or word) and its level.
464 const iconGap = <Box flexDirection="row" width={1} flexShrink={0} />
465 const ponyIcon =
466 ponyAs === 'text' ? (
467 <Text dimColor>ponytail:</Text>
468 ) : e.surface === 'terminal' && 'Image' in els ? (
469 <els.Image source={{ png: PONYTAIL_PNG }} columns={2} rows={1} alt="ponytail:" />
470 ) : e.surface !== 'terminal' && 'Svg' in els ? (
471 <els.Svg
472 alt="Ponytail"
473 width={14}
474 height={16}
475 source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 48"><image width="42" height="48" href="data:image/png;base64,${PONYTAIL_PNG}"/></svg>`}
476 />
477 ) : (
478 <Text dimColor>ponytail:</Text>
479 )
480 // A plugin's level picks its own: a dropdown showing the current one where the surface draws
481 // one; on mobile, which has none, a press steps to the next, strongest first and round.
482 const Select = 'Select' in els && e.surface !== 'mobile' ? els.Select : undefined
483 const picker = (key: string, current: string, levels: string[], pick: (v: string) => Promise<unknown>) =>
484 Select ? (
485 <Select
486 key={key}
487 value={current}
488 options={(levels.includes(current) ? levels : [...levels, current]).map(value => ({ value }))}
489 onSelect={(v: string) => void (v !== current && pick(v))}
490 />
491 ) : (
492 <Button
493 key={key}
494 label={` ${current}`}
495 plain
496 dimColor
497 hover={{ scope: key, underline: true }}
498 onPress={() => void pick(levels[(levels.indexOf(current) + 1) % levels.length] ?? current)}
499 />
500 )
501 const level =
502 pony === 'not installed' ? (
503 <Text dimColor> {pony}</Text>
504 ) : (
505 picker('ponytail-level', pony, LEVELS, v => switchPonytail($, v))
506 )
507 // Caveman's logo is its trademark, so a generic rock stands for it; nothing shows where it
508 // isn't installed.
509 const caveSegment = cave
510 ? [
511 widget(
512 caveAs === 'text' ? <Text dimColor>caveman:</Text> : <Text>🪨</Text>,
513 iconGap,
514 // Megacave answers in Classical Chinese, so it's offered only when asked for.
515 picker('caveman-mode', cave, megacave ? CAVE_MODES : CAVE_MODES.filter(m => m !== 'megacave'), v => switchCaveman($, v)),
516 ),
517 ]
518 : []
519 const LIMIT = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } as Record<string, string>
520 const nowMs = await $.clock.now().catch(() => Date.now())
521 const samples = runout !== 'off' ? await read($, limitSamples) : {}
522 // Run-out at its pace: amber before the reset, red under 30 minutes; always also marks a limit
523 // that lasts. Nothing until there's a pace.
524 const runOut = (r: { kind: string; resetsAt?: string }) => {
525 const out = runout !== 'off' ? runsOutAt(samples[r.kind] ?? [], r.resetsAt) : undefined
526 return out === undefined ? []
527 : out === Infinity ? (runout === 'always' ? [<Text dimColor> ✓ lasts</Text>] : [])
528 : [<Text color={heat(out - nowMs < 30 * 60_000 ? 100 : 70)}> ⚠{untilReset(new Date(out).toISOString(), nowMs)}</Text>]
529 }
530 // Subagents still running, the model's and plugins' alike; pressing the count lists them.
531 const running = agents ? (await $.agent.list().catch(() => [])).filter(a => a.status === 'running') : []
532 const agentSegment = running.length
533 ? [
534 widget(
535 <Text dimColor>⧗ </Text>,
536 <Button
537 key="agents"
538 label={`${running.length} agent${running.length === 1 ? '' : 's'}`}
539 plain
540 hover={{ scope: 'agents', underline: true }}
541 onPress={() => void update($, panel, p => (p === 'agents' ? '' : 'agents'))}
542 />,
543 ),
544 ]
545 : []
546 const usageSegments = [
547 ...(limits && usage?.rateLimits.length
548 ? [
549 widget(
550 ...usage.rateLimits.flatMap((r, i) => [
551 ...(i ? [<Text dimColor> · </Text>] : []),
552 <Text dimColor>{LIMIT[r.kind] ?? r.kind} </Text>,
553 <Text color={heat(r.percentUsed)}>{Math.round(r.percentUsed)}%</Text>,
554 ...(resets && r.resetsAt ? [<Text dimColor> ↻{untilReset(r.resetsAt, nowMs)}</Text>] : []),
555 ...runOut(r),
556 ]),
557 ),
558 ]
559 : []),
560 // With the limits off, each run-out stands alone under its limit's name.
561 ...(!limits && usage
562 ? usage.rateLimits.flatMap(r => {
563 const f = runOut(r)
564 return f.length ? [widget(<Text dimColor>{LIMIT[r.kind] ?? r.kind}</Text>, ...f)] : []
565 })
566 : []),
567 ...(cost && usage?.cost ? [widget(<Text dimColor>${usage.cost.usd.toFixed(2)}</Text>)] : []),
568 ]
569 // Only while something is down: a count that opens the MCP errors box.
570 const mcpSegment = down.length
571 ? [
572 widget(
573 <Text color={heat(100)}>⚠ </Text>,
574 <Button
575 key="mcp"
576 label={`${down.length} MCP error${down.length === 1 ? '' : 's'}`}
577 plain
578 hover={{ scope: 'mcp', underline: true }}
579 onPress={() => void update($, panel, p => (p === 'mcp' ? '' : 'mcp'))}
580 />,
581 ),
582 ]
583 : []
584 // Refused tool calls, until dismissed: a count that opens the Tool denials box.
585 const denialSegment = refused.length
586 ? [
587 widget(
588 <Text color={heat(100)}>⛔ </Text>,
589 <Button
590 key="denials"
591 label={`${refused.length} denied`}
592 plain
593 hover={{ scope: 'denials', underline: true }}
594 onPress={() => void update($, panel, p => (p === 'denials' ? '' : 'denials'))}
595 />,
596 ),
597 ]
598 : []
599 const ponySegment = [...mcpSegment, ...denialSegment, ...agentSegment, ...usageSegments, ...(pony ? [widget(ponyIcon, iconGap, level)] : []), ...caveSegment]
600 // No tooltip prop: each card is drawn hidden over the Config box's blank row, revealed by its
601 // hover scope.
602 // Each card ends with the setting's command, so the prompt can set it too.
603 const tip = (scope: keyof typeof TIPS) => {
604 const field = scope.slice('cfg-'.length) as Field
605 return (
606 <Box position="absolute" top={0} left={0} display="none" hover={{ scope, display: 'flex' }} flexDirection="row">
607 <Text inverse> {TIPS[scope]} · /overalls {field} {FIELDS[field].join('|')} </Text>
608 </Box>
609 )
610 }
611 const set = async (field: string, value: string) => {
612 const deny = await setPref($, options, field, value)
613 if (deny) $.ui.toast(`Couldn't set ${field}: ${deny}`)
614 }
615 // ⚙ opens a Config box above the band with every choice, the current one ticked, so each
616 // change shows in the band as it's made.
617 const choice = (field: string, value: string, current: boolean) => (
618 <Button key={`${field}-${value}`} label={`${value}${current ? ' ✓' : ''}`} plain dimColor={!current} onPress={() => void set(field, value)} />
619 )
620 // Settings sit in two bordered columns (the band's slot is capped at half the window's height).
621 const setting = (field: Field, name: string, choices: (ReturnType<typeof Button> | ReturnType<typeof Text>)[]) => (
622 <Box flexDirection="row">
623 <Box flexDirection="row" width={10}>
624 <Text hover={{ scope: `cfg-${field}`, underline: true }}>{name}</Text>
625 </Box>
626 {...choices.flatMap(c => [c, <Text> </Text>])}
627 </Box>
628 )
629 const shown = await read($, panel)
630 const open = shown === 'config'
631 // The Config box offers Install on a plugin's row where it isn't installed.
632 const missing = async (plugin: keyof typeof SOURCES) => open && !(await isInstalled($, plugin).catch(() => true))
633 const install = async (plugin: keyof typeof SOURCES) =>
634 (await missing(plugin))
635 ? [<Text dimColor>│</Text>, <Button key={`install-${plugin}`} label="Install" plain onPress={() => void askToInstall($, plugin)} />]
636 : []
637 const [installPonytail, installCaveman] = await Promise.all([install('ponytail'), install('caveman')])
638 const settings = (
639 // A Button takes no bold: while the row is open ⚙ is drawn as the primary button instead.
640 open ? (
641 <Button key="settings" label="⚙" variant="primary" onPress={() => void update($, panel, p => (p === 'config' ? '' : 'config'))} />
642 ) : (
643 <Button key="settings" label="⚙" plain dimColor onPress={() => void update($, panel, p => (p === 'config' ? '' : 'config'))} />
644 )
645 )
646 const inApp = shown === 'mcp' && (await $.env.get('CLAUDE_CODE_ENTRYPOINT').catch(() => undefined)) === 'claude-desktop'
647 // One row per failing server: its name, why, the error as Claude saw it, and what to do.
648 const mcpBox = (
649 <Box flexDirection="column" borderStyle="round" borderColor={heat(100)} paddingX={1} marginBottom={1}>
650 <Box flexDirection="row">
651 <Text bold>MCP errors</Text>
652 <Box flexDirection="row" flexGrow={1} />
653 <Button key="mcp-dismiss-all" label="Dismiss all" plain dimColor onPress={() => void dismissMcp($)} />
654 </Box>
655 <Text> </Text>
656 {...down.map(([server, t]) => (
657 <Box flexDirection="row" gap={2}>
658 <Box flexDirection="row" width={14}>
659 <Text>{mcpLabel(server)}</Text>
660 </Box>
661 <Text color={heat(100)}>{t.why}</Text>
662 <Box flexDirection="row" flexGrow={1} flexShrink={1}>
663 <Text dimColor>{t.detail}</Text>
664 </Box>
665 {isConnector(server) && inApp ? (
666 <Button key={`mcp-reconnect-${server}`} label="Reconnect" plain onPress={() => void openConnectors($)} />
667 ) : isConnector(server) ? (
668 <Link href={CONNECTORS_URL} label="Reconnect" />
669 ) : (
670 <Button key={`mcp-reconnect-${server}`} label="Reconnect" plain onPress={() => void reconnectMcp($, e.surface ?? '', server)} />
671 )}
672 <Button key={`mcp-dismiss-${server}`} label="Dismiss" plain dimColor onPress={() => void dismissMcp($, server)} />
673 </Box>
674 ))}
675 </Box>
676 )
677 const agentsBox = (
678 <Box flexDirection="column" borderStyle="round" paddingX={1} marginBottom={1}>
679 <Text bold>Agents running</Text>
680 <Text> </Text>
681 {...running.map(a => (
682 <Box flexDirection="row" gap={2}>
683 <Box flexDirection="row" width={18}>
684 <Text dimColor>{a.type}</Text>
685 </Box>
686 <Text>{a.description.replace(/\s+/g, ' ').slice(0, 80)}</Text>
687 </Box>
688 ))}
689 </Box>
690 )
691 // One row per refused call, newest last: the tool and why, with Dismiss.
692 const denialsBox = (
693 <Box flexDirection="column" borderStyle="round" borderColor={heat(100)} paddingX={1} marginBottom={1}>
694 <Box flexDirection="row">
695 <Text bold>Tool denials</Text>
696 <Box flexDirection="row" flexGrow={1} />
697 <Button key="denials-dismiss-all" label="Dismiss all" plain dimColor onPress={() => void update($, denials, () => [])} />
698 </Box>
699 <Text> </Text>
700 {...refused.map((d, i) => (
701 <Box flexDirection="row" gap={2}>
702 <Box flexDirection="row" width={18}>
703 <Text>{d.tool.startsWith('mcp__') ? `${mcpLabel(mcpServer(d.tool))} ${d.tool.split('__')[2] ?? ''}` : d.tool}</Text>
704 </Box>
705 <Box flexDirection="row" flexGrow={1} flexShrink={1}>
706 <Text dimColor>{d.reason}</Text>
707 </Box>
708 <Button key={`denials-dismiss-${i}`} label="Dismiss" plain dimColor onPress={() => void update($, denials, prev => prev.filter((_, j) => j !== i))} />
709 </Box>
710 ))}
711 </Box>
712 )
713 // Settings sit in two columns in the band, whose height is capped, and in one in the Pane.
714 const configBox = (
715 <Box flexDirection="column" borderStyle="round" paddingX={1} marginBottom={1}>
716 <Text bold>Config</Text>
717 <Box flexDirection="row">
718 <Text> </Text>
719 {...(Object.keys(TIPS) as (keyof typeof TIPS)[]).map(tip)}
720 </Box>
721 <Box flexDirection={inPane ? 'column' : 'row'} gap={1}>
722 <Box flexDirection="column" width={inPane ? '100%' : '50%'} borderStyle="round" borderDimColor paddingX={1}>
723 {setting('weather', 'Weather', DETAILS.map(d => choice('weather', d, d === detail)))}
724 {setting('turns', 'Turns', ONOFF.map(m => choice('turns', m, (m === 'on') === turns)))}
725 {setting('caveman', 'Caveman', [
726 ...PONY.map(m => choice('caveman', m, m === caveAs)),
727 // Megacave belongs to Caveman, so its switch shares the row, past a divider.
728 <Text dimColor>│</Text>,
729 <Text hover={{ scope: 'cfg-megacave', underline: true }}>megacave</Text>,
730 ...ONOFF.map(m => choice('megacave', m, (m === 'on') === megacave)),
731 ...installCaveman,
732 ])}
733 {setting('limits', 'Limits', [
734 ...ONOFF.map(m => choice('limits', m, (m === 'on') === limits)),
735 // Resets belong to the limits, so their switch shares the row, past a divider.
736 <Text dimColor>│</Text>,
737 <Text hover={{ scope: 'cfg-resets', underline: true }}>resets</Text>,
738 ...ONOFF.map(m => choice('resets', m, (m === 'on') === resets)),
739 ])}
740 {setting('agents', 'Agents', ONOFF.map(m => choice('agents', m, (m === 'on') === agents)))}
741 </Box>
742 <Box flexDirection="column" width={inPane ? '100%' : '50%'} borderStyle="round" borderDimColor paddingX={1}>
743 {setting('view', 'View', VIEWS.map(v => choice('view', v, v === view)))}
744 {setting('ponytail', 'Ponytail', [...PONY.map(m => choice('ponytail', m, m === ponyAs)), ...installPonytail])}
745 {setting('cost', 'Cost', ONOFF.map(m => choice('cost', m, (m === 'on') === cost)))}
746 {setting('mcp', 'MCP', ONOFF.map(m => choice('mcp', m, (m === 'on') === mcp)))}
747 {setting('denials', 'Denials', ONOFF.map(m => choice('denials', m, (m === 'on') === showDenials)))}
748 {setting('runout', 'Run-out', RUNOUT.map(m => choice('runout', m, m === runout)))}
749 </Box>
750 </Box>
751 </Box>
752 )
753 const withPanel = (band: ReturnType<typeof Box>) =>
754 shown === 'denials' && refused.length ? (
755 <Box flexDirection="column" width="100%">
756 {denialsBox}
757 {band}
758 </Box>
759 ) : shown === 'agents' && running.length ? (
760 <Box flexDirection="column" width="100%">
761 {agentsBox}
762 {band}
763 </Box>
764 ) : shown === 'mcp' && down.length ? (
765 <Box flexDirection="column" width="100%">
766 {mcpBox}
767 {band}
768 </Box>
769 ) : !open ? band : (
770 <Box flexDirection="column" width="100%">
771 {configBox}
772 {band}
773 </Box>
774 )
775 const now = readings.at(-1)
776 if (!now) return band(ponySegment)
777
778 const percent = Math.round(pct(now))
779 const w = weather(percent)
780 const delta = now.tokens - (readings.at(-2)?.tokens ?? 0)
781 const left = turnsLeft(readings)
782
783 // Pressing the forecast steps minimal → normal → full → minimal; off is the Config box's.
784 const cycle = () => set('weather', DETAILS[(DETAILS.indexOf(detail) % 3) + 1] ?? 'full')
785
786 const compact = async () => {
787 try {
788 const r = await $.session.compact()
789 if (r.skip !== undefined) $.ui.toast(`Compact skipped: ${r.skip}`)
790 else await recordDrop($, r.tokensAfter)
791 } catch {
792 // Headless sessions (the desktop app) can't compact from a plugin yet: run /compact as if
793 // typed, else leave it in the box to send. session.compact above records the drop.
794 await $.command.run({ command: 'compact' }).catch(async () => {
795 const r = await $.prompt.fill({ text: '/compact' })
796 if (!r.isFilled) $.ui.toast("Couldn't compact here: run /compact")
797 })
798 }
799 }
800
801 // Block characters sit on the font's baseline and widen in the desktop app's font, so the remote
802 // surfaces get the bars as an Svg the height of a line; the terminal keeps the characters.
803 const sparkline =
804 e.surface !== 'terminal' && 'Svg' in els ? (
805 <els.Svg alt={`Context over the last ${readings.length} turns`} width={readings.length * 5} height={14} source={sparkSvg(readings)} />
806 ) : (
807 <Text>{...readings.map(r => <Text color={heat(pct(r))}>{bar(r)}</Text>)}</Text>
808 )
809 const forecast = (
810 <Button key="detail" label={detail === 'minimal' ? `${percent}%` : w.word} plain hover={{ scope: 'forecast', underline: true }} onPress={cycle} />
811 )
812 return band(
813 [
814 widget(
815 <Text color={heat(percent)}>{w.icon} </Text>,
816 forecast,
817 ...(detail === 'minimal' ? [] : [<Text> {percent}% · {fmt(now.tokens)} / {fmt(now.window)}</Text>]),
818 ),
819 ...(detail === 'full'
820 ? [
821 widget(sparkline),
822 widget(<Text dimColor>{delta >= 0 ? '▲ +' : '▼ -'}{fmt(Math.abs(delta))} last turn</Text>),
823 ]
824 : []),
825 ...(turns && left !== undefined
826 ? [widget(<Text color={left <= 3 ? heat(100) : undefined} dimColor={left > 3}>⌛ ~{left} turn{left === 1 ? '' : 's'}</Text>)]
827 : []),
828 ...ponySegment,
829 ],
830 ...(percent >= COMPACT_AT && !(!inPane && e.props.isWorking)
831 ? // Started, not awaited: compacting a large context outlasts a press's 10 s budget, and the
832 // session.compact hook records the drop whenever it finishes.
833 [<Button key="compact" label="Compact" variant="primary" onPress={() => void compact()} />]
834 : []),
835 )
836}
837
838export const register: Register = (on, options) => {
839 on('session.start', async ($, e, next) => {
840 await $.command.register({ name: 'overalls', description: 'Set the Overalls band', argumentHint: USAGE })
841 await refreshPonytail($, options)
842 // Opened unasked, a Pane seats only where the window is wide enough (144 columns).
843 if ((await prefs($, options)).view !== 'bar') await applyView($, 'pane')
844 // Redraw every 30 s, so reset countdowns and the agent count move while nothing else happens,
845 // and sample the limits for when they run out (a render can't write). A reload drops this
846 // environment's timers, so there is only ever the one.
847 try {
848 $.clock.every(30_000, () => void sampleLimits($).finally(() => $.ui.invalidate('ui.render')))
849 } catch {}
850 return next(e)
851 })
852
853 on('command.run', { command: 'overalls' }, async ($, e) => {
854 const [field = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
855 const value = field in FIELDS && (FIELDS[field as Field] as readonly string[]).includes(arg) ? arg : undefined
856 if (value === undefined) {
857 const p = await prefs($, options)
858 const now = `weather: ${p.detail}, ponytail: ${p.pony}, caveman: ${p.cave}, megacave: ${p.megacave ? 'on' : 'off'}, limits: ${p.limits ? 'on' : 'off'}, resets: ${p.resets ? 'on' : 'off'}, agents: ${p.agents ? 'on' : 'off'}, turns: ${p.turns ? 'on' : 'off'}, denials: ${p.denials ? 'on' : 'off'}, runout: ${p.runout}, cost: ${p.cost ? 'on' : 'off'}, mcp: ${p.mcp ? 'on' : 'off'}, view: ${p.view}`
859 return { text: `Overalls ${now}\nUsage: /overalls ${USAGE}` }
860 }
861 const deny = await setPref($, options, field, value)
862 if (deny) return { text: `Couldn't set ${field}: ${deny}` }
863 if (field === 'view') await applyView($, value)
864 return { text: `Overalls ${field}: ${value}` }
865 })
866
867 // `/ponytail lite` and "stop ponytail" take effect as the prompt goes in.
868 on('prompt.submit', async ($, e, next) => {
869 const result = await next(e)
870 const to = levelAfter(e.text)
871 if (to) await update($, level, () => to === 'default' ? '' : to) // '' reads the default afresh
872 await refreshPonytail($, options)
873 return result
874 })
875
876 // Observed only: the result goes on as it came.
877 on('tool.call', async ($, e, next) => {
878 const result = await next(e)
879 const server = mcpServer(e.tool)
880 if (server) {
881 const text = result.isError ? result.text ?? String(result.result ?? '') : ''
882 const why = mcpTrouble(text)
883 if (why) await update($, mcpDown, prev => ({ ...prev, [server]: { why, detail: detailOf(text) } }))
884 else if (!result.isError && !('deny' in result && result.deny)) {
885 await update($, mcpDown, prev => {
886 const { [server]: _, ...rest } = prev
887 return server in prev ? rest : prev
888 })
889 }
890 }
891 const refusal = 'deny' in result && result.deny ? result.deny : result.isError && deniedBy(result.text ?? '') ? (result.text ?? '') : ''
892 if (refusal) await update($, denials, prev => [...prev, { tool: e.tool, reason: detailOf(refusal) }].slice(-20))
893 if (e.tool === 'ToolSearch' && !result.isError && result.result) {
894 const failed = (result.result as { failed_mcp_servers?: { name: string; error?: string }[] }).failed_mcp_servers ?? []
895 if (failed.length) {
896 await update($, mcpDown, prev => ({
897 ...prev,
898 ...Object.fromEntries(
899 failed.map(f => [f.name, { why: mcpTrouble(f.error ?? '') || 'failed', detail: detailOf(f.error ?? '') }]),
900 ),
901 }))
902 }
903 }
904 return result
905 })
906
907 on('turn.complete', async ($, e, next) => {
908 if (e.agentId) return next(e) // main-loop turns only, not subagents
909 const { context } = await $.session.usage()
910 if (context.tokens !== undefined) {
911 const reading = { tokens: context.tokens, window: context.window }
912 await update($, history, prev => [...prev, reading].slice(-12))
913 }
914 await refreshPonytail($, options)
915 return next(e)
916 })
917
918 // Any compaction records the drop at once rather than next turn.
919 on('session.compact', async ($, e, next) => {
920 const result = await next(e)
921 if (result.messages) {
922 await recordDrop($, result.tokensAfter)
923 // Ponytail activates afresh after a compaction, at its default.
924 await update($, level, () => '')
925 await refreshPonytail($, options)
926 }
927 return result
928 })
929
930 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
931 (await prefs($, options)).view === 'pane' ? next(e) : draw($, e, next, false, options),
932 )
933 on('ui.render', { component: 'Pane', requestId: 'overalls' }, ($, e, next) => draw($, e, next, true, options))
934}
935hooks/ponytail-icon.ts 4 lines1// assets/ponytail-logo-dark.png (Ponytail, MIT: assets/PONYTAIL-LICENSE) scaled to 42×48, base64.
2// The dark variant's face is filled white, so it reads on light and dark backgrounds alike.
3export const PONYTAIL_PNG = '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'
4types/index.d.ts 11 lines1export type Reading = { tokens: number; window: number }
2export type McpTrouble = { why: string; detail: string }
3export type Denial = { tool: string; reason: string }
4export type Sample = { at: number; pct: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 overalls: { history: Reading[]; ponytail: string; level: string; caveman: string; mcpDown: Record<string, McpTrouble>; panel: '' | 'config' | 'mcp' | 'agents' | 'denials'; denials: Denial[]; limitSamples: Record<string, Sample[]> }
9 }
10}
11