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

bandguardcommandstatusprompttimer
v0.2.0MITupdated 2026-10-03yandy-r/ystack/ycc/mods/status-bar
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 ▴ × 08:53:20 ✓ Bash 20ms cat .env 08:53:20 ✓ Bash 20ms rm -rf build && git push --force origin main 08: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 ▴ × 08:53:20 ✓ Bash 20ms cat .env 08:53:20 ✓ Bash 20ms rm -rf build && git push --force origin main 08:53:20 ✓ Bash 20ms git status --porcelain
README

ystack

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 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-invocable skills, and 55 agents.

<!-- END:GENERATED-COUNTS -->

<!-- BEGIN:GENERATED-COMMANDS -->

Command / SkillPurpose
/ycc:backportBackports merged trunk PRs to maintenance branches.
/ycc:blueprintCreates whole-project blueprints and bootstraps new software.
/ycc:bundle-authorScaffolds ycc skills and agents for ycc maintainers only.
/ycc:bundle-releasePrepares ycc bundle releases for ycc maintainers only.
/ycc:cleanRemoves unnecessary project-directory files with approval.
/ycc:code-reportGenerates implementation reports from executed parallel plans.
/ycc:code-reviewReviews local changes or GitHub PRs and saves actionable findings.
/ycc:compatibility-auditAudits ycc compatibility for ycc maintainers only.
/ycc:deep-researchConducts multi-perspective strategic research.
/ycc:feature-researchResearches features with seven specialists and consolidates a feature spec.
/ycc:formattersBootstraps project linting and formatting tools.
/ycc:frontend-designBuilds frontend interfaces with intentional visual direction.
/ycc:frontend-patternsGuides React and Next.js component architecture and performance.
/ycc:git-cleanupAudits stale Git resources and cleans them with approval.
/ycc:git-workflowOrchestrates commits, documentation updates, pushes, and PRs.
/ycc:go-patternsUse when structuring Go packages or service layers, handling wrapped errors, or managing goroutines and context cancellation.
/ycc:go-testingGuides Go testing with TDD, table-driven tests, and fuzzing.
/ycc:hooks-workflowGenerates hook configuration from language rules.
/ycc:implement-planExecutes parallel-plan.md with dependency-aware implementor batches.
/ycc:initInitializes project AI-agent docs and workspace conventions.
/ycc:orchestrateCoordinates specialized agents in dependency-resolved batches.
/ycc:parallel-planCreates parallel-plan.md from existing shared.md through plan-workflow.
/ycc:planUse 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-workflowUse when validating existing research docs before planning, running a research-to-plan workflow, or producing shared.md and parallel-plan.md.
/ycc:pr-autofixApplies GitHub PR comments and resolves review threads.
/ycc:prp-commitStages selected changes and creates one Conventional Commit.
/ycc:prp-implementExecutes PRP plan files with validation loops, reports, and archiving.
/ycc:prp-planCreates self-contained PRP plans with codebase patterns.
/ycc:prp-prCreates GitHub PRs from already-committed branches without commit orchestration.
/ycc:prp-prdDevelops problem-first PRDs through interactive discovery.
/ycc:prp-specWrites lightweight single-pass PRP feature specs.
/ycc:python-patternsGuides idiomatic Python design and resource handling.
/ycc:python-testingGuides pytest testing with TDD, fixtures, and mocks.
/ycc:quick-fixApplies inline Quick Review findings without review artifacts.
/ycc:quick-reviewReviews uncommitted changes inline without saving by default.
/ycc:release-modelCreates, upgrades, or audits RELEASING.md branching policy.
/ycc:releaserPrepares external-project releases, notes, and release CI.
/ycc:research-to-issuesConverts research and planning documents into structured tracking issues.
/ycc:resume-sessionLoads saved session context and briefs the next step without starting work.
/ycc:review-fixApplies saved code-review findings and updates artifact status.
/ycc:rust-patternsGuides idiomatic Rust ownership, errors, and concurrency.
/ycc:rust-testingGuides Rust testing with TDD and property checks.
/ycc:save-sessionSaves session progress, failures, and next steps for later resumption.
/ycc:shared-contextCreates verified shared.md research context through plan-workflow.
/ycc:ts-patternsGuides framework-agnostic TypeScript types, modules, and async code.
/ycc:ts-testingGuides TypeScript testing with Vitest and TDD.
/ycc:visual-planRenders existing plans as interactive visual documents.
/ycc:visual-recapBuilds local-only visual recaps from Git diffs.
/ycc:write-docsCoordinates parallel documentation agents to audit and improve project docs.

<!-- 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 or agent, read the Scope & Guardrails policy in CONTRIBUTING.md.

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

Installation

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:

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
OMP (oh-my-pi)docs/install/omp.md
Shared installer conceptsdocs/install/README.md
Native packages (all runtimes)docs/install/v6/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

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