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, and opencode.
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 commands (most skills have a matching command), and 55 agents.
<!-- END:GENERATED-COUNTS -->
<!-- BEGIN:GENERATED-COMMANDS -->
| Command / Skill | Purpose |
|---|---|
/ycc:backport | Cherry-pick merged trunk PRs onto active maintenance branches (release/X.Y) and open backport PRs, following RELEASING.md and its backport:X.Y labels. |
/ycc:blueprint | Whole-project source-of-truth spec generator. |
/ycc:bundle-author | Maintainer-only (ycc repo) — scaffold new source-of-truth content in the ycc bundle (skill, optional matching command and agent) |
/ycc:bundle-release | Maintainer-only (ycc repo) — prepare a ycc bundle release: preflight, bump, regenerate, validate, draft notes (no auto-commit) |
/ycc:clean | Orchestrate parallel cleanup agents to find and remove unnecessary project files |
/ycc:code-report | Generate structured implementation reports documenting changes made during plan execution. |
/ycc:code-review | Code review — local uncommitted changes or a GitHub PR (pass PR number/URL for PR mode). |
/ycc:compatibility-audit | Maintainer-only (ycc repo) — audit cross-target compatibility of the ycc bundle across Claude, Cursor, Codex, and opencode targets |
/ycc:deep-research | Conduct strategic multi-perspective research using the Asymmetric Research Squad methodology — 8 specialized personas (historical, contrarian, analogical, systems, journalistic, ar... |
/ycc:feature-research | Research a feature comprehensively before implementation — analyzes requirements, gathers external API context, and produces a feature-spec.md ready for plan-workflow. |
/ycc:formatters | Bootstrap a best-practices lint/format environment — installs a self-contained scripts/style.sh bundle, per-language tool configs, runnable aliases (package.json/Makefile/justfile)... |
/ycc:frontend-design | Create distinctive, production-grade frontend interfaces with intentional visual direction — typography, color, spacing rhythm, layout composition, motion, and atmosphere. |
/ycc:frontend-patterns | Frontend patterns for React and Next.js — composition, compound components, render props, custom hooks, state management with Context+useReducer, data fetching, performance optimiz... |
/ycc:git-cleanup | Audit and clean up stale git resources (branches, worktrees, remote-tracking refs, stashes, tags, PRs, issues) on GitHub/GitLab. |
/ycc:git-workflow | Git commit and documentation workflow manager. |
/ycc:go-patterns | Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. |
/ycc:go-testing | Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. |
/ycc:hooks-workflow | Generate target-aware hook configuration from ycc rule guidance with graceful fallbacks. |
/ycc:implement-plan | Execute a parallel implementation plan by deploying implementor agents in dependency-resolved batches. |
/ycc:init | Initialize workspace — profile project, emit CLAUDE.md/AGENTS.md/.cursor/rules, optional GitHub templates, git conventions, and Claude CLI config. |
/ycc:orchestrate | Orchestrate multiple specialized agents to accomplish a complex task through intelligent decomposition and parallel execution. |
/ycc:parallel-plan | Generate a detailed parallel implementation plan with task dependencies, file ownership, and batch ordering. |
/ycc:plan | Lightweight conversational planner. |
/ycc:plan-workflow | Unified planning workflow — research, analyze, and generate parallel implementation plans in one command. |
/ycc:pr-autofix | Vendor-neutral PR comment auto-fix — discover every review thread, file-level review comment, and top-level PR comment from any author, dispatch per-comment fix agents, resolve thr... |
/ycc:prp-commit | Quick natural-language git commit helper — describe what to commit in plain English (blob glob, filter phrase, or topic). |
/ycc:prp-implement | Execute a PRP plan file with per-task validation loops. |
/ycc:prp-plan | Create a single-pass implementation plan from a feature description or PRD. |
/ycc:prp-pr | Create a GitHub PR from the current branch — discovers templates, analyzes commits, references PRP artifacts, pushes, and opens the PR via gh. |
/ycc:prp-prd | Interactive PRD generator — problem-first, hypothesis-driven product spec built through iterative questioning and dual-mode grounding research. |
/ycc:prp-spec | Generate a lightweight feature spec for the PRP workflow — single-pass with optional codebase/market grounding. |
/ycc:python-patterns | Idiomatic Python patterns, PEP 8 conventions, type hints, dataclasses, context managers, decorators, and best practices for building robust, maintainable Python applications. |
/ycc:python-testing | Python testing patterns using pytest — TDD methodology, fixtures (function/module/session scopes), parametrization, markers, mocking with unittest.mock, async tests with pytest-asy... |
/ycc:quick-fix | Apply fixes from an inline /ycc:quick-review findings block without creating a review artifact. |
/ycc:quick-review | Fast interactive review of uncommitted changes. |
/ycc:release-model | Create, upgrade, or audit a project's RELEASING.md branching and release model — surveys tags, release branches, CI, and version files, proposes trunk-only or release-branches, ren... |
/ycc:releaser | Prepare and cut a GitHub release for any project — detects toolchain, drafts changelog, plans platform/arch artifacts, optionally generates or audits release CI, optionally invokes... |
/ycc:research-to-issues | Convert research, feature specs, and implementation plans into structured GitHub issues with tracking hierarchy, labels, and priority. |
/ycc:resume-session | Load a saved session and produce a structured briefing; wait for user direction before starting work. |
/ycc:review-fix | Plan and apply fixes for findings from a /ycc:code-review artifact. |
/ycc:rust-patterns | Idiomatic Rust patterns, ownership, error handling, traits, concurrency, and best practices for building safe, performant applications. |
/ycc:rust-testing | Rust testing patterns including unit tests, integration tests, async testing, property-based testing, mocking, and coverage. |
/ycc:save-session | Save current session state to a dated file in ~/.claude/session-data/ so work can be resumed in a future session with full context. |
/ycc:shared-context | Build shared context documentation for a feature — gathers files, conventions, dependencies, and existing patterns into a single artifact that downstream planning stages can refere... |
/ycc:ts-patterns | Idiomatic TypeScript patterns — strict type system, discriminated unions, generic inference, satisfies, branded types, errors as values, ESM/CJS modules with exports maps, Prom... |
/ycc:ts-testing | TypeScript testing patterns using Vitest as the primary runner — TDD workflow, unit tests, integration tests, async tests with fake timers, parameterized tests via test.each, pro... |
/ycc:visual-plan | Turn an existing text plan into a rich, interactive visual plan — diagrams, file maps, annotated code, open questions, and an optional UI/prototype review surface. |
/ycc:visual-recap | Turn a finished change (git range, branch, or tracked working-tree diff) into a LOCAL-ONLY interactive visual recap — annotated split diffs, file map, schema/API deltas, diagrams,... |
/ycc:write-docs | Orchestrate 5 specialized documentation agents in parallel to analyze codebase and create comprehensive documentation. |
<!-- 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/..cursor-plugin/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 mention or the built-in task tool.Contributing: before proposing a new skill, command, or agent, read the Scope & Guardrails policy in CONTRIBUTING.md.
ycc:visual-plan and ycc:visual-recap are adapted from BuilderIO/skills.For Claude Code, install the published ycc plugin from the marketplace:
# Add the marketplace
/plugin marketplace add yandy-r/claude-plugins
# Install the bundle
/plugin install ycc@ycc
Or enable in ~/.claude/settings.json:
{
"enabledPlugins": {
"ycc@ycc": true
}
}
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 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; 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 |
| Shared installer concepts | docs/install/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.
This repository maintains generated compatibility trees under .cursor-plugin/, .codex-plugin/, and .opencode-plugin/. The canonical source of truth is the Claude-facing ycc/ tree.
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. Valid targets:
inventory — docs/inventory.json and the GENERATED-* regions of README.mdcursor — .cursor-plugin/ agents, skills, and rulescodex — .codex-plugin/ skills, agents, and plugin metadataopencode — .opencode-plugin/ skills, agents, commands, and plugin metadata (opencode.json + AGENTS.md)agents — .agents-plugin/ cross-tool skills for ~/.agents/skills (+ ycc-shared/)json — JSON-lint .claude-plugin/marketplace.json and ycc/.claude-plugin/plugin.json (validate only)Examples:
./scripts/sync.sh --only inventory
./scripts/validate.sh --only cursor,codex,opencode
./scripts/sync.sh --only opencode
CI runs ./scripts/validate.sh via .github/workflows/validate.yml and fails the job on any generated drift, so local and CI paths are identical.
ycc/settings/models.json owns OpenCode model policy. Every ycc/agents/*.md basename, plus built-in general and explore, must have a full provider/model entry in targets.opencode.agents. Select by intended capability: deep architecture or high-risk work uses openai/gpt-6-astra; docs, lookup, and routine work uses openai/gpt-5.6-terra; all other agents use openai/gpt-5.6-sol. These are placeholders (placeholder: true); users choose available provider models by capability and cost. opencode.json owns assignments; generated agent Markdown stays model-free. Update this mapping whenever an agent is added, renamed, or removed, then run ./scripts/sync.sh and ./scripts/validate.sh.
claude-plugins/
├── .claude-plugin/
│ └── marketplace.json # marketplace registry (single ycc entry)
├── .agents/
│ └── plugins/
│ └── marketplace.json # repo-local Codex marketplace source
├── .codex-plugin/
│ ├── agents/ # generated Codex custom agents (.toml)
│ └── ycc/ # generated Codex plugin root
│ ├── .codex-plugin/
│ │ └── plugin.json
│ ├── .mcp.json
│ ├── shared/
│ └── skills/
├── .cursor-plugin/ # Cursor IDE bundle (synced by install.sh install --target cursor)
│ ├── agents/ # generated from ycc/agents (run scripts/generate-cursor-agents.sh)
│ ├── rules/ # generated from ycc/rules (run scripts/generate-cursor-rules.sh)
│ └── skills/ # generated from ycc/skills (run scripts/generate-cursor-skills.sh)
├── .opencode-plugin/ # opencode bundle (synced by install.sh install --target opencode)
│ ├── agents/ # generated from ycc/agents
│ ├── commands/ # generated from ycc/commands
│ ├── skills/ # generated from ycc/skills
│ ├── shared/ # generated from ycc/skills/_shared
│ ├── AGENTS.md # generated rules file (transformed from CLAUDE.md)
│ └── opencode.json # generated config: $schema + default model + MCP
├── mcp-configs/
│ └── mcp.json # shared MCP servers; merged/copied by install.sh
├── install.sh # sync Claude/Cursor/Codex/opencode targets
├── scripts/
│ ├── generate-codex-skills.sh # wrapper → generate_codex_skills.py
│ ├── generate_codex_skills.py # ycc/skills → .codex-plugin/ycc/skills
│ ├── validate-codex-skills.sh # sync check + Codex skill lint/content policy
│ ├── generate-codex-agents.sh # wrapper → generate_codex_agents.py
│ ├── generate_codex_agents.py # ycc/agents → .codex-plugin/agents
│ ├── validate-codex-agents.sh # sync check + TOML/content policy
│ ├── generate-codex-plugin.sh # wrapper → generate_codex_plugin.py
│ ├── generate_codex_plugin.py # plugin manifest + repo-local marketplace metadata
│ ├── validate-codex-plugin.sh # JSON + sync check
│ ├── generate-cursor-agents.sh # wrapper → generate_cursor_agents.py
│ ├── generate_cursor_agents.py # ycc/agents 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