SLOPSHOPPER

status-bar

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

newbandguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /status-bar ⎿ status-bar: **Status bar** ⎿ status-bar: ⎿ status-bar: - Model: claude-opus-5-5 ⎿ status-bar: - Context: 49% (97k / 200k) ⎿ status-bar: - Cost: $0.42 ⎿ status-bar: - Prompts: 1 claude-opus-5-5 ████████████████ 49% 97k/200k $0.42 5h 31% ⚒ 9 ✗1 ▴ × 01:53:20 ✓ Bash 20ms cat .env 01:53:20 ✓ Bash 20ms rm -rf build && git push --force origin main 01:53:20 ✓ Bash 20ms git status --porcelain ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ status-bar: ctx 49% · $0.42 · 9 tools · 1 err

Draws

Band
claude-opus-5-5 ████████████████ 49% 97k/200k $0.42 5h 31% ⚒ 9 ✗1 ▴ × 01:53:20 ✓ Bash 20ms cat .env 01:53:20 ✓ Bash 20ms rm -rf build && git push --force origin main 01:53:20 ✓ Bash 20ms git status --porcelain
README

claude-plugins

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 single ycc plugin so every skill is accessible via ycc:{skill}. Re-install after upgrading.

Migration: /ycc:ask and ycc:ask retired. For read-only codebase questions, ask the main session to delegate to ycc:codebase-advisor. Request explicit search gaps, assumptions, and analyzed/skipped scope in the answer. Request implementation separately.

What's inside

<!-- 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 / SkillPurpose
/ycc:backportCherry-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:blueprintWhole-project source-of-truth spec generator.
/ycc:bundle-authorMaintainer-only (ycc repo) — scaffold new source-of-truth content in the ycc bundle (skill, optional matching command and agent)
/ycc:bundle-releaseMaintainer-only (ycc repo) — prepare a ycc bundle release: preflight, bump, regenerate, validate, draft notes (no auto-commit)
/ycc:cleanOrchestrate parallel cleanup agents to find and remove unnecessary project files
/ycc:code-reportGenerate structured implementation reports documenting changes made during plan execution.
/ycc:code-reviewCode review — local uncommitted changes or a GitHub PR (pass PR number/URL for PR mode).
/ycc:compatibility-auditMaintainer-only (ycc repo) — audit cross-target compatibility of the ycc bundle across Claude, Cursor, Codex, and opencode targets
/ycc:deep-researchConduct strategic multi-perspective research using the Asymmetric Research Squad methodology — 8 specialized personas (historical, contrarian, analogical, systems, journalistic, ar...
/ycc:feature-researchResearch a feature comprehensively before implementation — analyzes requirements, gathers external API context, and produces a feature-spec.md ready for plan-workflow.
/ycc:formattersBootstrap 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-designCreate distinctive, production-grade frontend interfaces with intentional visual direction — typography, color, spacing rhythm, layout composition, motion, and atmosphere.
/ycc:frontend-patternsFrontend patterns for React and Next.js — composition, compound components, render props, custom hooks, state management with Context+useReducer, data fetching, performance optimiz...
/ycc:git-cleanupAudit and clean up stale git resources (branches, worktrees, remote-tracking refs, stashes, tags, PRs, issues) on GitHub/GitLab.
/ycc:git-workflowGit commit and documentation workflow manager.
/ycc:go-patternsIdiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications.
/ycc:go-testingGo testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage.
/ycc:hooks-workflowGenerate target-aware hook configuration from ycc rule guidance with graceful fallbacks.
/ycc:implement-planExecute a parallel implementation plan by deploying implementor agents in dependency-resolved batches.
/ycc:initInitialize workspace — profile project, emit CLAUDE.md/AGENTS.md/.cursor/rules, optional GitHub templates, git conventions, and Claude CLI config.
/ycc:orchestrateOrchestrate multiple specialized agents to accomplish a complex task through intelligent decomposition and parallel execution.
/ycc:parallel-planGenerate a detailed parallel implementation plan with task dependencies, file ownership, and batch ordering.
/ycc:planLightweight conversational planner.
/ycc:plan-workflowUnified planning workflow — research, analyze, and generate parallel implementation plans in one command.
/ycc:pr-autofixVendor-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-commitQuick natural-language git commit helper — describe what to commit in plain English (blob glob, filter phrase, or topic).
/ycc:prp-implementExecute a PRP plan file with per-task validation loops.
/ycc:prp-planCreate a single-pass implementation plan from a feature description or PRD.
/ycc:prp-prCreate a GitHub PR from the current branch — discovers templates, analyzes commits, references PRP artifacts, pushes, and opens the PR via gh.
/ycc:prp-prdInteractive PRD generator — problem-first, hypothesis-driven product spec built through iterative questioning and dual-mode grounding research.
/ycc:prp-specGenerate a lightweight feature spec for the PRP workflow — single-pass with optional codebase/market grounding.
/ycc:python-patternsIdiomatic Python patterns, PEP 8 conventions, type hints, dataclasses, context managers, decorators, and best practices for building robust, maintainable Python applications.
/ycc:python-testingPython testing patterns using pytest — TDD methodology, fixtures (function/module/session scopes), parametrization, markers, mocking with unittest.mock, async tests with pytest-asy...
/ycc:quick-fixApply fixes from an inline /ycc:quick-review findings block without creating a review artifact.
/ycc:quick-reviewFast interactive review of uncommitted changes.
/ycc:release-modelCreate, 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:releaserPrepare 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-issuesConvert research, feature specs, and implementation plans into structured GitHub issues with tracking hierarchy, labels, and priority.
/ycc:resume-sessionLoad a saved session and produce a structured briefing; wait for user direction before starting work.
/ycc:review-fixPlan and apply fixes for findings from a /ycc:code-review artifact.
/ycc:rust-patternsIdiomatic Rust patterns, ownership, error handling, traits, concurrency, and best practices for building safe, performant applications.
/ycc:rust-testingRust testing patterns including unit tests, integration tests, async testing, property-based testing, mocking, and coverage.
/ycc:save-sessionSave 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-contextBuild 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-patternsIdiomatic TypeScript patterns — strict type system, discriminated unions, generic inference, satisfies, branded types, errors as values, ESM/CJS modules with exports maps, Prom...
/ycc:ts-testingTypeScript 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-planTurn 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-recapTurn 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-docsOrchestrate 5 specialized documentation agents in parallel to analyze codebase and create comprehensive documentation.

<!-- END:GENERATED-COMMANDS -->

Maintainer-only workflows

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

Agents

<!-- BEGIN:GENERATED-AGENTS -->

The plugin bundles 55 specialized agents covering codebase analysis, language experts (Go, Rust, Python, TypeScript), reviewers, planners, documenters, and infrastructure architects.

  • Language experts & implementors (12): 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-architect
  • Code review & quality (7): backport-conflict-resolver, code-reviewer, code-simplifier, pr-comment-fixer, release-fix-applier, review-fixer, rust-reviewer
  • Research & discovery (10): code-explorer, code-finder, code-researcher, codebase-advisor, feature-researcher, library-docs-writer, practices-researcher, prp-researcher, research-specialist, root-cause-analyzer
  • Architecture & planning (5): architect, architecture-analyst, code-architect, planner, test-strategy-planner
  • Documentation (7): api-docs-expert, api-documenter, code-documenter, docs-git-committer, documentation-writer, feature-writer, readme-generator
  • Infrastructure & DevOps (7): ansible-automation-expert, cloudflare-architect, cloudflare-developer, reverse-proxy-architect, systems-engineering-expert, terraform-architect, terraform-developer
  • Databases (3): db-modifier, sql-database-developer, turso-database-architect
  • Workflow utilities (4): git-cleanup, implementor, project-file-cleaner, releaser

<!-- END:GENERATED-AGENTS -->

Contributing: before proposing a new skill, command, or agent, read the Scope & Guardrails policy in CONTRIBUTING.md.

  • Credits: ycc:visual-plan and ycc:visual-recap are adapted from BuilderIO/skills.

Installation

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:

RuntimeInstall guide
Claude Code / Claude Desktopdocs/install/claude.md
Cursordocs/install/cursor.md
Codex / Codex Desktopdocs/install/codex.md
opencodedocs/install/opencode.md
~/.agents/skills (Zed etc)docs/install/agents.md
Shared installer conceptsdocs/install/README.md

Workflow composition

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.

Development Sync

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.md
  • cursor — .cursor-plugin/ agents, skills, and rules
  • codex — .codex-plugin/ skills, agents, and plugin metadata
  • opencode — .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.

OpenCode Per-Agent Model Policy

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.

Repository layout

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 
Source 3 files
hooks/register.tsx 324 lines
1import { 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}
324
hooks/format.ts 162 lines
1import 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  )
162
types/index.d.ts 79 lines
1export 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