SLOPSHOPPER

overalls

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

newpanebandguardcommandtoast
★ 1v0.8.0MITupdated 2026-10-04Troepster/overalls
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overalls
│ ┃ overalls ✕ › fix the failing auth test and add an audit log call │ ┃ ⚙ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ☁ Cloudy 49% · 97.4k / 200k ▄ ▲ +97.4k las ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /overalls │ ⎿ overalls: Overalls weather: full, ponytail: icon, caveman: icon, │ ⎿ overalls: Usage: /overalls weather off|minimal|normal|full · pon │ │ ☁ Cloudy 49% · 97.4k / 200k ▄ ▲ +97.4k last turn 5h 31% ↻NaNm ▣ ponytail: not installed ⚙ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% · 97.4k / 200k ▄ ▲ +97.4k last turn 5h 31% ↻NaNm ▣ ponytail: not installed ⚙
Pane · overalls
⚙ ☁ Cloudy 49% · 97.4k / 200k ▄ ▲ +97.4k last turn 5h 31% ↻
README

overalls

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.

The band in the desktop app: forecast, sparkline, last-turn change, usage limits, session cost, and the Ponytail and Caveman dropdowns, with ⚙ at the right

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.

Install

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.

Settings

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 Config box open above the band: every choice for each setting in two columns, the current one ticked; below it the band, wrapped onto a second row

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.

SettingValuesDefaultWhat it does
Viewbar · pane · bothbarWhere 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 detailoff · minimal · normal · full (typed; anything else counts as full)fullHow much of the forecast to show
Ponytail leveloff · icon · text (typed; anything else counts as icon, and an old on/off value as icon/off)iconShow whether Ponytail is installed and its level, after its logo or the word ponytail:
Caveman modeoff · icon · text (typed; anything else counts as icon)iconShow Caveman's mode where it's installed, after 🪨 or the word caveman:
Offer megacaveon · offoffList Caveman's Classical Chinese mode (megacave) in its dropdown; in the Config box it sits on Caveman's row
Usage limitson · offonShow how much of your 5-hour and weekly limits is spent (subscriptions only)
Limit resetson · offonShow how long until each limit resets (5h 42% ↻1h20m); in the Config box it sits on the Limits row
Limit run-outoff · warn · always (typed; anything else counts as warn)warnWarn 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 coston · offoffShow what this session has cost so far
MCP warningson · offonWarn when an MCP server or connector fails or needs signing in again
Running agentson · offonShow ⧗ 2 agents while subagents run; pressing it lists them (type and task)
Turns lefton · offonShow ⌛ ~6 turns: about how many more turns fit, at the average growth of the last five that grew (red from 3)
Tool denialson · offonShow ⛔ 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:

LevelBand
minimal☂ 67%
normal☂ Showers 67% · 134.4k / 200k
fullnormal plus the last 12 turns as a sparkline and the change since last turn
offnothing (the Ponytail segment still shows if enabled)

Ponytail detection

<img src="assets/ponytail-logo.png" width="110" alt="Ponytail, the lazy senior dev">

  • Installed: an enabled ponytail@… entry in enabledPlugins. Otherwise the band shows not installed beside the logo.
  • Level: this session's own. Ponytail keeps a single level file for every session, so it can't say what this session runs at; Overalls tracks it per session instead, by Ponytail's rules:
  • a new session, and a compaction (Ponytail activates afresh), start at Ponytail's default: $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.
  • Switched by picking a level from the dropdown the level is drawn as (on mobile, which has no dropdown, a press steps ultra → full → lite → off), through Ponytail's own /ponytail <level> command.

Installing Ponytail or Caveman

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

  • Installed: an enabled caveman@… entry in enabledPlugins; otherwise nothing shows.
  • Mode: this session's own. Caveman keeps each session's mode in .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.
  • Switched by picking a mode from its dropdown (ultracave, caveman, off, and megacave when Offer megacave is on, since it answers in Classical Chinese; on mobile a press steps through them), through Caveman's own /megacave, /ultracave, /caveman and /caveman off commands.

MCP warnings

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:

  • an mcp__<server>__… call that fails to sign in or connect (a bad-arguments error doesn't count), and
  • the servers a ToolSearch reports as failed.

The 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:

  • Reconnect: for a claude.ai connector, Customize → Connectors, opened in the desktop app itself there (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.
  • Dismiss, and Dismiss all

A server leaves the list on its next successful call, or when dismissed. It's caught on its first failure, not before.

Data and access

Overalls makes no network requests and sends nothing anywhere. Everything it does stays inside your Claude Code session:

  • reads the session's context-window usage ($.session.usage()) after each turn
  • reads your Claude Code settings to see whether Ponytail is enabled
  • reads the session's usage-limit percentages, reset times and cost ($.session.usage()) when those widgets are on, and the clock to count down to each reset
  • reads the session's list of subagents ($.agent.list(): type, task, status) when the agents widget is on
  • redraws the band every 30 seconds, so countdowns and the agent count stay current, and at each redraw notes the usage-limit percentages ($.session.usage()), keeping the last hour of them in session memory to tell when a limit will run out
  • reads your Claude Code settings to see whether Caveman is enabled and, if it is, this session's id and Caveman's mode file for it in your Claude config directory: .caveman-sessions/<session id>.mode (or .caveman-active when there's no session id)
  • reads Ponytail's config.json, for its default level, and the PONYTAIL_DEFAULT_MODE, XDG_CONFIG_HOME, CLAUDE_CONFIG_DIR and HOME environment variables
  • reads each prompt you submit only to spot a Ponytail command (/ponytail …, stop ponytail, normal mode), keeping nothing of it but the level
  • keeps the last 12 readings and the Ponytail level in session state, which is gone when the session ends
  • switches Ponytail's level or Caveman's mode only when you pick one from the band: it runs /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
  • types an install request into the prompt box only when you press Install in the Config box; nothing is installed unless you send it and approve Claude's commands
  • reads the name of each tool Claude calls and, when one is refused (by you, a hook, a permission rule or the auto-mode classifier), keeps the tool's name and the first 100 characters of the reason in session state until you dismiss it
  • reads the name of each MCP tool Claude calls, the error text when one fails, and the failed-server list a ToolSearch returns, keeping only each failing server's name, whether it needs signing in or failed, and the first 100 characters of its error; it runs /mcp only when you press Reconnect in the terminal
  • reads CLAUDE_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
  • compacts the conversation only when you press its Compact button; where Claude Code doesn't let a plugin compact (the desktop app), that press runs /compact for you, or types it into the prompt box if it can't run it
  • changes its own settings only when you run /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.

Development

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.

Licence

MIT

Credits

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.

Source 3 files
hooks/register.tsx 935 lines
1import { 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}
935
hooks/ponytail-icon.ts 4 lines
1// 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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'
4
types/index.d.ts 11 lines
1export 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