Context window as a stacked bar above the prompt, one color per /context category, plus cost, rate limits and recent tool calls

A single Claude Code plugin (ycc) bundling workflow orchestration, parallel planning, documentation, research, and project management. The repository also generates native compatibility bundles for Cursor, Codex, opencode, and OMP (oh-my-pi).
2.0.0 breaking change. Versions ≤ 1.x shipped 9 separate plugins (
ask,plan-workflow,git-workflow,implement-plan,code-report,deep-research,orchestrate,write-docs,project). 2.0.0 collapses all of them into a singleyccplugin so every skill is accessible viaycc:{skill}. Re-install after upgrading. Migration:/ycc:askandycc:askretired. For read-only codebase questions, ask the main session to delegate toycc:codebase-advisor. Request explicit search gaps, assumptions, and analyzed/skipped scope in the answer. Request implementation separately.
<!-- BEGIN:GENERATED-COUNTS -->
The source plugin ships 49 skills, 49 slash-invocable skills, and 55 agents.
<!-- END:GENERATED-COUNTS -->
<!-- BEGIN:GENERATED-COMMANDS -->
| Command / Skill | Purpose |
|---|---|
/ycc:backport | Backports merged trunk PRs to maintenance branches. |
/ycc:blueprint | Creates whole-project blueprints and bootstraps new software. |
/ycc:bundle-author | Scaffolds ycc skills and agents for ycc maintainers only. |
/ycc:bundle-release | Prepares ycc bundle releases for ycc maintainers only. |
/ycc:clean | Removes unnecessary project-directory files with approval. |
/ycc:code-report | Generates implementation reports from executed parallel plans. |
/ycc:code-review | Reviews local changes or GitHub PRs and saves actionable findings. |
/ycc:compatibility-audit | Audits ycc compatibility for ycc maintainers only. |
/ycc:deep-research | Conducts multi-perspective strategic research. |
/ycc:feature-research | Researches features with seven specialists and consolidates a feature spec. |
/ycc:formatters | Bootstraps project linting and formatting tools. |
/ycc:frontend-design | Builds frontend interfaces with intentional visual direction. |
/ycc:frontend-patterns | Guides React and Next.js component architecture and performance. |
/ycc:git-cleanup | Audits stale Git resources and cleans them with approval. |
/ycc:git-workflow | Orchestrates commits, documentation updates, pushes, and PRs. |
/ycc:go-patterns | Use when structuring Go packages or service layers, handling wrapped errors, or managing goroutines and context cancellation. |
/ycc:go-testing | Guides Go testing with TDD, table-driven tests, and fuzzing. |
/ycc:hooks-workflow | Generates hook configuration from language rules. |
/ycc:implement-plan | Executes parallel-plan.md with dependency-aware implementor batches. |
/ycc:init | Initializes project AI-agent docs and workspace conventions. |
/ycc:orchestrate | Coordinates specialized agents in dependency-resolved batches. |
/ycc:parallel-plan | Creates parallel-plan.md from existing shared.md through plan-workflow. |
/ycc:plan | Use when comparing two implementation options or walking through tradeoffs before committing to one, breaking a change into steps, or planning conversationally for approval. |
/ycc:plan-workflow | Use when validating existing research docs before planning, running a research-to-plan workflow, or producing shared.md and parallel-plan.md. |
/ycc:pr-autofix | Applies GitHub PR comments and resolves review threads. |
/ycc:prp-commit | Stages selected changes and creates one Conventional Commit. |
/ycc:prp-implement | Executes PRP plan files with validation loops, reports, and archiving. |
/ycc:prp-plan | Creates self-contained PRP plans with codebase patterns. |
/ycc:prp-pr | Creates GitHub PRs from already-committed branches without commit orchestration. |
/ycc:prp-prd | Develops problem-first PRDs through interactive discovery. |
/ycc:prp-spec | Writes lightweight single-pass PRP feature specs. |
/ycc:python-patterns | Guides idiomatic Python design and resource handling. |
/ycc:python-testing | Guides pytest testing with TDD, fixtures, and mocks. |
/ycc:quick-fix | Applies inline Quick Review findings without review artifacts. |
/ycc:quick-review | Reviews uncommitted changes inline without saving by default. |
/ycc:release-model | Creates, upgrades, or audits RELEASING.md branching policy. |
/ycc:releaser | Prepares external-project releases, notes, and release CI. |
/ycc:research-to-issues | Converts research and planning documents into structured tracking issues. |
/ycc:resume-session | Loads saved session context and briefs the next step without starting work. |
/ycc:review-fix | Applies saved code-review findings and updates artifact status. |
/ycc:rust-patterns | Guides idiomatic Rust ownership, errors, and concurrency. |
/ycc:rust-testing | Guides Rust testing with TDD and property checks. |
/ycc:save-session | Saves session progress, failures, and next steps for later resumption. |
/ycc:shared-context | Creates verified shared.md research context through plan-workflow. |
/ycc:ts-patterns | Guides framework-agnostic TypeScript types, modules, and async code. |
/ycc:ts-testing | Guides TypeScript testing with Vitest and TDD. |
/ycc:visual-plan | Renders existing plans as interactive visual documents. |
/ycc:visual-recap | Builds local-only visual recaps from Git diffs. |
/ycc:write-docs | Coordinates parallel documentation agents to audit and improve project docs. |
<!-- END:GENERATED-COMMANDS -->
/ycc:bundle-author, /ycc:bundle-release, and /ycc:compatibility-audit are maintainer-only workflows for this repo's layout. General users can ignore them — they are not general-purpose tooling. They stay shipped in the single ycc plugin (not excluded from generated targets): /ycc:bundle-release backs the release flow in RELEASING.md, and the other two stay for single-plugin parity rather than because scripts import them.
<!-- BEGIN:GENERATED-AGENTS -->
The plugin bundles 55 specialized agents covering codebase analysis, language experts (Go, Rust, Python, TypeScript), reviewers, planners, documenters, and infrastructure architects.
frontend-ui-developer, go-api-architect, go-expert-architect, nextjs-ux-ui-expert, nodejs-backend-architect, nodejs-backend-developer, python-developer, python-expert-architect, rust-build-resolver, rust-expert-architect, typescript-developer, typescript-expert-architectbackport-conflict-resolver, code-reviewer, code-simplifier, pr-comment-fixer, release-fix-applier, review-fixer, rust-reviewercode-explorer, code-finder, code-researcher, codebase-advisor, feature-researcher, library-docs-writer, practices-researcher, prp-researcher, research-specialist, root-cause-analyzerarchitect, architecture-analyst, code-architect, planner, test-strategy-plannerapi-docs-expert, api-documenter, code-documenter, docs-git-committer, documentation-writer, feature-writer, readme-generatoransible-automation-expert, cloudflare-architect, cloudflare-developer, reverse-proxy-architect, systems-engineering-expert, terraform-architect, terraform-developerdb-modifier, sql-database-developer, turso-database-architectgit-cleanup, implementor, project-file-cleaner, releaser<!-- END:GENERATED-AGENTS -->
subagent_type: "ycc:{agent-name}". Canonical source lives in ycc/agents/.packages/cursor/ycc/agents/, produced from ycc/agents/. See the Cursor install guide..codex-plugin/agents/ (produced from ycc/agents/ — see the Codex install guide)..opencode-plugin/agents/ (produced from ycc/agents/ — see the opencode install guide). Invoke via @agent-name or the native v2 subagent tool.packages/omp/ycc/agents/ (produced from ycc/agents/ — see the OMP install guide). They are OMP task agents with @role models.Contributing: before proposing a new skill or agent, read the Scope & Guardrails policy in CONTRIBUTING.md.
ycc:visual-plan and ycc:visual-recap are adapted from BuilderIO/skills.ycc is published as native packages in the Claude Code, Codex, and Cursor marketplaces of this repository. For Claude Code, install the plugins from the marketplace:
# Add the marketplace
/plugin marketplace add yandy-r/ystack
# Install the bundle
/plugin install ycc@ycc
# Optional add-ons: MCP servers, hooks (Claude Code >= 2.1.300), or everything at once
/plugin install ycc-mcp@ycc
/plugin install ycc-hooks@ycc
/plugin install ycc-all@ycc
Codex and Cursor:
codex plugin marketplace add https://github.com/yandy-r/ystack.git
codex plugin add ycc@ycc
codex plugin add ycc-mcp@ycc
agent plugin marketplace add https://github.com/yandy-r/ystack # then pick packages in /plugins
Rules, instructions, settings, and Codex custom agents stay on install.sh. Already installed through install.sh? Preview the move onto the native packages with ./install.sh migrate --target codex (or --target claude / --target cursor; see Migrating To Native Packages).
Or enable in ~/.claude/settings.json:
{
"enabledPlugins": {
"ycc@ycc": true
}
}
OMP (oh-my-pi, omp >= 18.12.2) installs ycc, ycc-mcp and ycc-rules from this repository's OMP catalog:
omp plugin marketplace add https://github.com/yandy-r/ystack.git
omp plugin install ycc@ycc ycc-mcp@ycc ycc-rules@ycc
Or run ./install.sh install --target omp from a checkout (add --project for this project only, --dry-run to preview; native Windows is unsupported, use WSL 2). See docs/install/omp.md.
For local development installs, generated compatibility bundles, model defaults, and desktop app notes, use the dedicated install guides. Recommended sync form:
./install.sh sync --target claude --intent hooks,settings,mcp,plugins
./install.sh sync --target claude --intent mods # Claude Code mods (ycc/mods)
./install.sh sync --target all --intent settings,rules
./install.sh install --target all --only agents # standalone agents, no plugin
./install.sh install --target omp # OMP via omp plugin
./install.sh sync --target codex,claude,opencode --intent mcp # current project
./install.sh sync --target codex,claude,opencode --intent mcp --global # user-global
./install.sh sync --target opencode --intent mcp --mcps github,linear # only these servers
./install.sh list mcps # managed MCP server names
./install.sh remove --target all --only mcp # undo project MCP
./install.sh remove --target all --intent settings,rules # undo any intent, any target
./install.sh cli && ycc completion --install # `ycc` on PATH for other projects
The mcp step defaults to the current project; pass --global for user-global config. base ships a target's whole bundle (the ycc plugin for Claude and Codex). skills, agents and commands instead install standalone entries, one per skill/agent/command, into each tool's own directories (~/.claude/skills/<name>, ~/.codex/agents/<name>.toml, ...) with no plugin. On Claude, commands is an alias for skills (skills are the slash commands); your own entries there are never touched. remove undoes any intent on any target (base, skills, agents, commands, settings, rules, mcp, hooks, plugins, mods), taking back only what the installer owns and leaving your own keys, files and servers alone. See Project Vs Global Scope.
--target takes a comma-separated list (or all); every target is validated before any of them runs.
Structured config is merged rather than copied: repo-managed keys update while unknown and locally edited keys remain intact. First-time setup uses install (./install.sh install --target <t> [--settings --rules --mcp --hooks]); every invocation needs a command — a bare --target ... is rejected.
Dedicated guides:
| Runtime | Install guide |
|---|---|
| Claude Code / Claude Desktop | docs/install/claude.md |
| Cursor | docs/install/cursor.md |
| Codex / Codex Desktop | docs/install/codex.md |
| opencode | docs/install/opencode.md |
~/.agents/skills (Zed etc) | docs/install/agents.md |
| OMP (oh-my-pi) | docs/install/omp.md |
| Shared installer concepts | docs/install/README.md |
| Native packages (all runtimes) | docs/install/v6/README.md |
The planning skills compose into a pipeline:
ycc:feature-research → ycc:shared-context → ycc:parallel-plan → ycc:implement-plan → ycc:code-report → ycc:git-workflow
(research) (gather files) (design tasks) (deploy agents) (document) (commit/PR)
Use ycc:plan-workflow to run the full pipeline, or invoke individual stages. ycc:shared-context and ycc:parallel-plan are thin aliases over ycc:plan-workflow --research-only and --plan-only respectively; the pipeline and its artifacts are unchanged.
Generated trees live under packages/cursor/, packages/claude/, packages/omp/, .agents-plugin/, .codex-plugin/, and .opencode-plugin/. The canonical source is the Claude-facing ycc/ tree. Do not hand-edit generated files.
Regenerate and validate derived artifacts with:
./scripts/sync.sh # regenerate inventory and compatibility bundles
./scripts/validate.sh # run every validator; CI runs this on push and PR
Both accept --only <targets> with comma-separated values.
sync.sh targets are inventory, cursor, codex, opencode, omp, agents, and claude.
inventory updates docs/inventory.json and the GENERATED-* regions of README.md.cursor writes native packages under packages/cursor/.codex writes .codex-plugin/ skills, agents, and plugin metadata.opencode writes .opencode-plugin/ skills, agents, commands, opencode.json, and AGENTS.md.omp writes OMP (oh-my-pi) skills under packages/omp/ycc/skills/, plus the hidden ycc-shared skill for _shared, task agents under packages/omp/ycc/agents/, one /ycc:<skill> command entrypoint per public skill under packages/omp/ycc/commands/, the packages/omp/ycc-mcp/.mcp.json MCP config, the flat packages/omp/ycc-rules/rules/ycc-*.md rules, and the package manifests. The root .omp-plugin/marketplace.json lists the OMP packages marked listed in ycc/settings/packages.json: ycc, ycc-mcp, and ycc-rules.agents writes .agents-plugin/ cross-tool skills for ~/.agents/skills, plus ycc-shared/.claude writes the native add-on packages under packages/claude/ (ycc-mcp, ycc-hooks, and ycc-all).validate.sh accepts those names and also claude-native, cursor-native, codex-native, json, config, packages, release, mods, practices, scope, and types.
omp checks generated OMP skill, agent, command and package-manifest drift (bytes and modes), content policy, command inventory parity, catalog listing, lockstep versions, package closure, and generator golden tests.claude-native, cursor-native, and codex-native check generated native package drift, runtime policy, and fixtures.json checks .claude-plugin/marketplace.json and ycc/.claude-plugin/plugin.json.packages checks ycc/settings/packages.json, dependency closures, and catalog parity.config tests managed-config merge and the install.sh sync CLI.release runs scripts/test-release-*.sh.mods checks the Claude Code mods under ycc/mods.practices checks the authoring ratchet in scripts/practices-baseline.json.scope checks the changed-path mapping behind --changed.types runs mypy --strict over the modules listed in pyproject.toml [tool.mypy], with mypy pinned in scripts/requirements-mypy.txt (installed by CI; locally a matching python3 -m mypy or uvx).Examples:
./scripts/sync.sh --only inventory
./scripts/sync.sh --only claude
./scripts/sync.sh --only cursor
./scripts/validate.sh --only cursor,cursor-native,packages
hooks/register.tsx 324 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BarView, ContextSlice, ToolCallRecord, ToolTally, TurnInfo, UsageSnapshot } from '../types'
5import {
6 fmtClock,
7 fmtDelta,
8 fmtMs,
9 fmtTokens,
10 fmtUsd,
11 heat,
12 legendSlices,
13 limitLabel,
14 sliceGlyph,
15 stackedBar,
16 STATUS_COLOR,
17 STATUS_GLYPH,
18 summarize,
19 tallyCall,
20 toolLabel,
21 totals,
22} from './format'
23
24const KEEP_CALLS = 200
25const RECENT_CALLS = 3
26const LEGEND_SLICES = 4
27const VIEW_STORE_KEY = 'view'
28
29const calls = atom({ plugin: 'status-bar', key: 'calls' } as const, [] as ToolCallRecord[])
30const tallies = atom({ plugin: 'status-bar', key: 'tallies' } as const, [] as ToolTally[])
31const usage = atom({ plugin: 'status-bar', key: 'usage' } as const, null as UsageSnapshot | null)
32const lastTurn = atom({ plugin: 'status-bar', key: 'lastTurn' } as const, null as TurnInfo | null)
33const turnTools = atom({ plugin: 'status-bar', key: 'turnTools' } as const, 0)
34const tick = atom({ plugin: 'status-bar', key: 'tick' } as const, 0)
35const view = atom({ plugin: 'status-bar', key: 'view' } as const, 'full' as BarView)
36
37const VIEWS: readonly BarView[] = ['full', 'compact', 'hidden']
38
39/**
40 * Pulls the status line's figures, and /context's categories (a local
41 * estimate, no token-count request), into state, and mirrors a one-liner to
42 * the status line. `turnStartTokens` marks a turn's end: the delta is taken
43 * against it, otherwise the last delta carries over.
44 */
45const refreshUsage = async ($: EngineInterface, turnStartTokens?: number): Promise<void> => {
46 const [u, model, turns, prev] = await Promise.all([
47 $.session.usage({ breakdown: 'summary' }),
48 $.session.model(),
49 $.session.turns(),
50 read($, usage),
51 ])
52 const breakdown = u.context.breakdown
53 const slices: ContextSlice[] = (breakdown?.categories ?? []).flatMap(c =>
54 c.kind === 'deferred' ? [] : [{ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }],
55 )
56 const tokens = u.context.tokens
57 const snapshot: UsageSnapshot = {
58 model,
59 tokens,
60 window: u.context.window,
61 percent: u.context.percent,
62 slices,
63 sliceWindow: breakdown?.rawMaxTokens ?? u.context.window,
64 deltaTokens:
65 turnStartTokens !== undefined && tokens !== undefined ? tokens - turnStartTokens : prev?.deltaTokens,
66 costUsd: u.cost?.usd,
67 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
68 startedAt: u.startedAt,
69 turns,
70 }
71 await update($, usage, () => snapshot)
72
73 const sum = totals(await read($, tallies))
74 const parts = [
75 `ctx ${snapshot.percent ?? 0}%`,
76 snapshot.costUsd === undefined ? undefined : fmtUsd(snapshot.costUsd),
77 `${sum.count} tools`,
78 sum.errors > 0 ? `${sum.errors} err` : undefined,
79 ]
80 $.ui.status(parts.filter(Boolean).join(' · '))
81}
82
83const summaryMarkdown = (
84 u: UsageSnapshot | null,
85 t: TurnInfo | null,
86 list: readonly ToolTally[],
87 now: number,
88): string => {
89 const sum = totals(list)
90 const lines = ['**Status bar**', '']
91 if (u !== null) {
92 lines.push(
93 `- Model: ${u.model}`,
94 `- Context: ${u.percent ?? 0}% (${fmtTokens(u.tokens ?? 0)} / ${fmtTokens(u.window)})`,
95 `- Cost: ${u.costUsd === undefined ? 'n/a' : fmtUsd(u.costUsd)}`,
96 `- Prompts: ${u.turns}`,
97 `- Session: ${fmtMs(now - u.startedAt)}`,
98 ...u.rateLimits.map(r => `- Limit ${limitLabel(r.kind)}: ${r.percentUsed}%${r.resetsAt ? ` (resets ${r.resetsAt})` : ''}`),
99 )
100 if (u.slices.length > 0) {
101 lines.push('', `| Context (of ${fmtTokens(u.sliceWindow)}) | Tokens | Share |`, `| --- | ---: | ---: |`)
102 for (const s of u.slices) {
103 lines.push(`| ${s.name} | ${fmtTokens(s.tokens)} | ${Math.round((s.tokens / Math.max(1, u.sliceWindow)) * 100)}% |`)
104 }
105 }
106 }
107 if (t !== null) {
108 lines.push(
109 `- Last turn: ${fmtMs(t.durationMs)}, ${t.tools} tool calls, ${t.reason}` +
110 ` (in ${fmtTokens(t.inputTokens)}, out ${fmtTokens(t.outputTokens)},` +
111 ` cache r/w ${fmtTokens(t.cacheReadTokens)}/${fmtTokens(t.cacheWriteTokens)})`,
112 )
113 }
114 lines.push('', `| Tool | Calls | Errors | Denied | Total time | Avg |`, `| --- | ---: | ---: | ---: | ---: | ---: |`)
115 for (const row of list) {
116 lines.push(`| ${row.tool} | ${row.count} | ${row.errors} | ${row.denied} | ${fmtMs(row.totalMs)} | ${fmtMs(row.totalMs / row.count)} |`)
117 }
118 lines.push(`| **all** | ${sum.count} | ${sum.errors} | ${sum.denied} | ${fmtMs(sum.totalMs)} | |`)
119 return lines.join('\n')
120}
121
122export const register: Register = on => {
123 let running = 0
124 let turnStartTokens: number | undefined
125
126 on('session.start', async ($, e, next) => {
127 // A reload drops in-flight bookkeeping; anything still "running" is orphaned.
128 await update($, calls, list => list.map(c => (c.status === 'running' ? { ...c, status: 'lost' as const } : c)))
129 const saved = await $.store.get(VIEW_STORE_KEY)
130 if (typeof saved === 'string' && (VIEWS as readonly string[]).includes(saved)) {
131 await update($, view, () => saved as BarView)
132 }
133 await $.command.register({
134 name: 'status-bar',
135 description: 'Status bar: full | compact | hide | show | reset; no args prints a full summary',
136 })
137 $.clock.every(1000, () => {
138 if (running > 0) void update($, tick, n => n + 1)
139 })
140 $.clock.every(15_000, () => void refreshUsage($))
141 await refreshUsage($)
142
143 return next(e)
144 })
145
146 on('command.run', { command: 'status-bar' }, async ($, e) => {
147 const arg = e.args.trim().toLowerCase()
148 const setView = async (v: BarView) => {
149 await update($, view, () => v)
150 await $.store.set(VIEW_STORE_KEY, v)
151 return { text: `Status bar: ${v}.` }
152 }
153 if (arg === 'full' || arg === 'show') return setView('full')
154 if (arg === 'compact') return setView('compact')
155 if (arg === 'hide' || arg === 'hidden') return setView('hidden')
156 if (arg === 'reset') {
157 await update($, calls, () => [])
158 await update($, tallies, () => [])
159 return { text: 'Status bar counters reset.' }
160 }
161 await refreshUsage($)
162 const [u, t, list, now] = await Promise.all([read($, usage), read($, lastTurn), read($, tallies), $.clock.now()])
163 return { text: summaryMarkdown(u, t, list, now) }
164 })
165
166 on('prompt.submit', async ($, e, next) => {
167 await update($, turnTools, () => 0)
168 turnStartTokens = (await read($, usage))?.tokens ?? 0
169 return next(e)
170 })
171
172 on('tool.call', async ($, e, next) => {
173 const call: ToolCallRecord = {
174 id: e.tool_use_id,
175 tool: e.tool,
176 summary: summarize(e.tool, e as unknown as Record<string, unknown>),
177 startedAt: await $.clock.now(),
178 status: 'running',
179 agentId: e.agentId,
180 }
181 running += 1
182 await update($, calls, list => [...list, call].slice(-KEEP_CALLS))
183
184 try {
185 const ran = await next(e)
186 const done: ToolCallRecord = {
187 ...call,
188 durationMs: (await $.clock.now()) - call.startedAt,
189 status: ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok',
190 isReadOnly: ran.isReadOnly,
191 resultChars: ran.text?.length,
192 }
193 await update($, calls, list => list.map(c => (c.id === call.id ? done : c)))
194 await update($, tallies, list => tallyCall(list, done))
195 if (e.agentId === undefined) await update($, turnTools, n => n + 1)
196 return ran
197 } finally {
198 running = Math.max(0, running - 1)
199 }
200 })
201
202 on('turn.complete', async ($, e, next) => {
203 const result = await next(e)
204 if (e.agentId === undefined) {
205 const u = e.usage
206 const info: TurnInfo = {
207 durationMs: e.durationMs,
208 tools: await read($, turnTools),
209 reason: e.reason,
210 model: u?.model,
211 inputTokens: u?.input_tokens ?? 0,
212 outputTokens: u?.output_tokens ?? 0,
213 cacheReadTokens: u?.cache_read_input_tokens ?? 0,
214 cacheWriteTokens: u?.cache_creation_input_tokens ?? 0,
215 }
216 await update($, lastTurn, () => info)
217 await refreshUsage($, turnStartTokens)
218 }
219 return result
220 })
221
222 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
223 const mode = await read($, view)
224 if (e.props.hasSurvey || mode === 'hidden') return next(e)
225
226 const { Box, Button, Text } = $.ui.resolve(e)
227 await read($, tick) // subscribe: running timers redraw each second
228 const [u, list, tally, now] = await Promise.all([read($, usage), read($, calls), read($, tallies), $.clock.now()])
229 const cols = e.props.bodyColumns
230 const sum = totals(tally)
231
232 // Measure against what the bar draws: /context's categories when the
233 // breakdown is in, else the status line's single figure.
234 const slices: ContextSlice[] =
235 u === null
236 ? []
237 : u.slices.length > 0
238 ? u.slices
239 : [{ name: 'Used', tokens: u.tokens ?? 0, color: heat(u.percent ?? 0), kind: 'used' }]
240 const ctxWindow = u === null ? 0 : u.slices.length > 0 ? u.sliceWindow : u.window
241 const ctxUsed = slices.filter(s => s.kind === 'used').reduce((n, s) => n + s.tokens, 0)
242 const pct = ctxWindow > 0 ? Math.round((ctxUsed / ctxWindow) * 100) : 0
243 const delta = fmtDelta(u?.deltaTokens)
244
245 const header = (
246 <Box flexDirection="row" gap={1}>
247 <Text bold>{u?.model ?? '…'}</Text>
248 <Box flexDirection="row">
249 {stackedBar(slices, ctxWindow, cols >= 120 ? 24 : cols >= 90 ? 16 : 10).map(seg => (
250 <Text color={seg.color}>{seg.text}</Text>
251 ))}
252 </Box>
253 <Text color={heat(pct)} bold>{pct}%</Text>
254 <Text dimColor>
255 {fmtTokens(ctxUsed)}/{fmtTokens(ctxWindow)}
256 </Text>
257 {delta !== '' && <Text dimColor>{delta}</Text>}
258 {u?.costUsd !== undefined && <Text color="green">{fmtUsd(u.costUsd)}</Text>}
259 {(u?.rateLimits ?? []).map(r => (
260 <Text color={heat(r.percentUsed)}>
261 {limitLabel(r.kind)} {Math.round(r.percentUsed)}%
262 </Text>
263 ))}
264 {sum.count > 0 && (
265 <Text color={sum.errors > 0 ? 'red' : undefined} dimColor={sum.errors === 0}>
266 ⚒ {sum.count}
267 {sum.errors > 0 ? ` ✗${sum.errors}` : ''}
268 </Text>
269 )}
270 <Button
271 key="mode"
272 plain
273 label={mode === 'full' ? '▴' : '▾'}
274 onPress={() => update($, view, v => (v === 'full' ? 'compact' : 'full'))}
275 />
276 <Button key="hide" plain label="×" onPress={() => update($, view, () => 'hidden')} />
277 </Box>
278 )
279 if (mode === 'compact') return header
280
281 const legend = cols >= 60 ? legendSlices(u?.slices ?? [], LEGEND_SLICES) : []
282 const inFlight = list.filter(c => c.status === 'running')
283 const recentRoom = Math.max(0, Math.min(RECENT_CALLS, e.props.maxRows - 2 - inFlight.length))
284 const recent = recentRoom === 0 ? [] : list.filter(c => c.status !== 'running').slice(-recentRoom).reverse()
285 const shownCalls = [...inFlight, ...recent]
286 const toolCol = Math.min(18, Math.max(6, ...shownCalls.map(c => toolLabel(c.tool).length)))
287
288 const row = (c: ToolCallRecord) => (
289 <Box flexDirection="row" gap={1}>
290 <Text dimColor>{fmtClock(c.startedAt)}</Text>
291 <Text color={STATUS_COLOR[c.status]}>{STATUS_GLYPH[c.status]}</Text>
292 <Text bold>{toolLabel(c.tool).padEnd(toolCol)}</Text>
293 <Text dimColor>
294 {fmtMs(c.status === 'running' ? now - c.startedAt : (c.durationMs ?? 0)).padStart(6)}
295 </Text>
296 {c.agentId !== undefined && <Text color="magenta">↳{c.agentId.slice(0, 6)}</Text>}
297 <Text dimColor wrap="truncate-end">
298 {c.summary}
299 </Text>
300 </Box>
301 )
302
303 return (
304 <Box flexDirection="column">
305 {header}
306 {legend.length > 0 && (
307 <Box flexDirection="row" gap={2}>
308 {legend.map(s => (
309 <Text wrap="truncate-end">
310 <Text color={s.color}>{sliceGlyph(s.kind)}</Text>
311 <Text dimColor>
312 {' '}
313 {s.name} {fmtTokens(s.tokens)}
314 </Text>
315 </Text>
316 ))}
317 </Box>
318 )}
319 {shownCalls.map(row)}
320 </Box>
321 )
322 })
323}
324hooks/format.ts 162 lines1import type { BarSegment, CallStatus, ContextSlice, ToolCallRecord, ToolTally } from '../types'
2
3type Args = Record<string, unknown>
4
5const str = (input: Args, key: string): string | undefined => {
6 const value = input[key]
7 return typeof value === 'string' && value.length > 0 ? value : undefined
8}
9
10const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
11
12/** `mcp__github__get_me` → `github:get_me`; built-ins pass through. */
13export const toolLabel = (tool: string): string => {
14 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
15 if (mcp === null) return tool
16 const server = (mcp[1] ?? '').replace(/^plugin_[^_]+_/, '')
17 return `${server}:${mcp[2] ?? ''}`
18}
19
20/** The one-line gist of a call's arguments, per tool. */
21export const summarize = (tool: string, input: Args): string => {
22 const path = str(input, 'file_path') ?? str(input, 'notebook_path') ?? str(input, 'path')
23 switch (tool) {
24 case 'Bash':
25 return oneLine(str(input, 'command') ?? '')
26 case 'Read':
27 case 'Write':
28 case 'Edit':
29 case 'NotebookEdit':
30 return path ?? ''
31 case 'Grep':
32 return oneLine(`/${str(input, 'pattern') ?? ''}/ ${path ?? ''}`)
33 case 'Glob':
34 return oneLine(`${str(input, 'pattern') ?? ''} ${path ?? ''}`)
35 case 'WebFetch':
36 return str(input, 'url') ?? ''
37 case 'WebSearch':
38 return str(input, 'query') ?? ''
39 case 'Agent':
40 case 'Task':
41 return oneLine(`${str(input, 'subagent_type') ?? 'agent'}: ${str(input, 'description') ?? ''}`)
42 case 'Skill':
43 return oneLine(`${str(input, 'skill') ?? ''} ${str(input, 'args') ?? ''}`)
44 case 'TodoWrite':
45 return Array.isArray(input.todos) ? `${input.todos.length} todos` : ''
46 }
47 for (const value of Object.values(input)) {
48 if (typeof value === 'string' && value.length > 0) return oneLine(value)
49 }
50 return ''
51}
52
53export const fmtMs = (ms: number): string => {
54 if (ms < 1000) return `${Math.round(ms)}ms`
55 const s = ms / 1000
56 if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`
57 const m = Math.floor(s / 60)
58 if (m < 60) return `${m}m${Math.round(s % 60)}s`
59 return `${Math.floor(m / 60)}h${m % 60}m`
60}
61
62export const fmtTokens = (n: number): string => {
63 if (n < 1000) return String(n)
64 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
65 return `${(n / 1_000_000).toFixed(2)}M`
66}
67
68export const fmtUsd = (usd: number): string => `$${usd.toFixed(usd < 10 ? 2 : 1)}`
69
70export const fmtClock = (ms: number): string => {
71 const d = new Date(ms)
72 const pad = (n: number) => String(n).padStart(2, '0')
73 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
74}
75
76/** `five_hour` → `5h`, `seven_day` → `7d`. */
77export const limitLabel = (kind: string): string =>
78 ({ five_hour: '5h', seven_day: '7d', spend_limit: 'spend' })[kind] ?? kind
79
80const SLICE_GLYPH: Record<ContextSlice['kind'], string> = { used: '█', buffer: '▒', free: '░' }
81
82/**
83 * The context window as one row of `width` cells, a run per /context category
84 * in its own color. Cells go by largest remainder so the row is always `width`
85 * wide; a category too small for a cell shows only in the legend.
86 */
87export const stackedBar = (slices: readonly ContextSlice[], window: number, width: number): BarSegment[] => {
88 const shown = slices.filter(s => s.tokens > 0)
89 const total = Math.max(window, shown.reduce((n, s) => n + s.tokens, 0), 1)
90 const exact = shown.map(s => (s.tokens / total) * width)
91 const cells = exact.map(Math.floor)
92 const byRemainder = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
93 for (let left = width - cells.reduce((n, c) => n + c, 0), k = 0; left > 0; left--, k++) {
94 const pick = byRemainder[k % byRemainder.length]
95 if (pick === undefined) break
96 cells[pick.i] = (cells[pick.i] ?? 0) + 1
97 }
98 return shown.flatMap((s, i) => {
99 const n = cells[i] ?? 0
100 return n > 0 ? [{ color: s.color, text: SLICE_GLYPH[s.kind].repeat(n) }] : []
101 })
102}
103
104/** The largest used categories, then free space: what the legend names. */
105export const legendSlices = (slices: readonly ContextSlice[], max: number): ContextSlice[] => [
106 ...slices
107 .filter(s => s.kind === 'used' && s.tokens > 0)
108 .sort((a, b) => b.tokens - a.tokens)
109 .slice(0, max),
110 ...slices.filter(s => s.kind === 'free'),
111]
112
113export const sliceGlyph = (kind: ContextSlice['kind']): string => (kind === 'used' ? '■' : SLICE_GLYPH[kind])
114
115/** `▲ 12k` / `▼ 80k`; empty when nothing changed. */
116export const fmtDelta = (delta: number | undefined): string =>
117 delta === undefined || delta === 0 ? '' : `${delta > 0 ? '▲' : '▼'} ${fmtTokens(Math.abs(delta))}`
118
119/** Green under 50%, yellow under 80%, red above. */
120export const heat = (percent: number): string =>
121 percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green'
122
123export const STATUS_GLYPH: Record<CallStatus, string> = {
124 running: '▶',
125 ok: '✓',
126 error: '✗',
127 denied: '⊘',
128 lost: '?',
129}
130
131export const STATUS_COLOR: Record<CallStatus, string> = {
132 running: 'cyan',
133 ok: 'green',
134 error: 'red',
135 denied: 'yellow',
136 lost: 'gray',
137}
138
139export const tallyCall = (tallies: readonly ToolTally[], call: ToolCallRecord): ToolTally[] => {
140 const tool = toolLabel(call.tool)
141 const prior = tallies.find(t => t.tool === tool) ?? { tool, count: 0, errors: 0, denied: 0, totalMs: 0 }
142 const next: ToolTally = {
143 tool,
144 count: prior.count + 1,
145 errors: prior.errors + (call.status === 'error' ? 1 : 0),
146 denied: prior.denied + (call.status === 'denied' ? 1 : 0),
147 totalMs: prior.totalMs + (call.durationMs ?? 0),
148 }
149 return [...tallies.filter(t => t.tool !== tool), next].sort((a, b) => b.count - a.count)
150}
151
152export const totals = (tallies: readonly ToolTally[]) =>
153 tallies.reduce(
154 (sum, t) => ({
155 count: sum.count + t.count,
156 errors: sum.errors + t.errors,
157 denied: sum.denied + t.denied,
158 totalMs: sum.totalMs + t.totalMs,
159 }),
160 { count: 0, errors: 0, denied: 0, totalMs: 0 },
161 )
162types/index.d.ts 79 lines1export type CallStatus = 'running' | 'ok' | 'error' | 'denied' | 'lost'
2
3export type ToolCallRecord = {
4 id: string
5 tool: string
6 summary: string
7 startedAt: number
8 durationMs?: number
9 status: CallStatus
10 agentId?: string
11 isReadOnly?: boolean
12 resultChars?: number
13}
14
15export type ToolTally = {
16 tool: string
17 count: number
18 errors: number
19 denied: number
20 totalMs: number
21}
22
23/** One /context category, as the stacked bar draws it. */
24export type ContextSlice = {
25 name: string
26 tokens: number
27 /** Theme key /context draws the category in. */
28 color: string
29 kind: 'used' | 'free' | 'buffer'
30}
31
32/** A run of same-category cells in the stacked bar. */
33export type BarSegment = { color: string; text: string }
34
35export type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
36
37export type UsageSnapshot = {
38 model: string
39 tokens?: number
40 window: number
41 percent?: number
42 /** /context's categories, measured against `sliceWindow` (the compaction window). */
43 slices: ContextSlice[]
44 sliceWindow: number
45 /** Context tokens the last main-loop turn added (negative after a compaction). */
46 deltaTokens?: number
47 costUsd?: number
48 rateLimits: RateLimitReading[]
49 startedAt: number
50 turns: number
51}
52
53export type TurnInfo = {
54 durationMs: number
55 tools: number
56 reason: string
57 model?: string
58 inputTokens: number
59 outputTokens: number
60 cacheReadTokens: number
61 cacheWriteTokens: number
62}
63
64export type BarView = 'full' | 'compact' | 'hidden'
65
66declare module 'claude-code' {
67 interface PluginState {
68 'status-bar': {
69 calls: ToolCallRecord[]
70 tallies: ToolTally[]
71 usage: UsageSnapshot | null
72 lastTurn: TurnInfo | null
73 turnTools: number
74 tick: number
75 view: BarView
76 }
77 }
78}
79