diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8f7cce2..0e83b5a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,11 +4,14 @@ "owner": { "name": "Samarth Rayar", "url": "https://github.com/Samarth2001" }, "plugins": [ { - "name": "buddy", - "source": "./plugins/buddy", - "description": "A pixel-art companion above the prompt that acts out what Claude is doing, with a sprout that wilts as context fills, a context sparkline, usage limits and cost", + "name": "hud", + "source": "./plugins/hud", + "description": "A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs and a dashboard pane", "category": "mods", - "tags": ["mod", "companion", "pixel-art", "status"] + "tags": ["mod", "usage", "cost", "limits", "dashboard"] } - ] + ], + "renames": { + "buddy": "hud" + } } diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 74da7df..6b361b5 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -10,5 +10,5 @@ Release plugin: $ARGUMENTS 1. Read `plugins//CHANGELOG.md` and run `git log --v..HEAD -- plugins/`. If that tag doesn't exist, use the full log for the folder. Then list what changed. 2. Choose the bump if none was given: patch for fixes, minor for features, major for breaking changes such as renamed commands or settings. Confirm it with me. 3. Set `version` in `plugins//.claude-plugin/plugin.json`. Add a `## [] - ` section to the CHANGELOG under Added, Changed or Fixed, plus its compare link at the bottom. -4. Run `claude plugin validate .`, `claude plugin validate plugins/`, `claude plugin test plugins/` if it has tests, and `node --experimental-strip-types scripts/catalog.mts --check`. +4. Run `claude plugin validate --strict .`, `claude plugin validate --strict plugins/`, `claude plugin test plugins/` if it has tests, and `node --experimental-strip-types scripts/catalog.mts --check`. 5. Commit as `: release `. Then stop and show me the commands to push and tag: `git push` and `claude plugin tag plugins/ --push`. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 03e9ee1..05c9739 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,13 +21,13 @@ jobs: - name: Install Claude Code run: npm install -g @anthropic-ai/claude-code - name: Validate marketplace - run: claude plugin validate . + run: claude plugin validate --strict . - name: Validate and test each plugin run: | set -e for dir in plugins/*/; do echo "::group::$dir" - claude plugin validate "$dir" + claude plugin validate --strict "$dir" if find "$dir" -name '*.test.ts' -o -name '*.test.tsx' | grep -q .; then claude plugin test "$dir" fi diff --git a/.gitignore b/.gitignore index 3ce7e6a..fbfeb36 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ plugins/*/.claude-plugin/types/ node_modules/ .DS_Store + +# Personal, machine-local Claude Code files: never published +CLAUDE.local.md +.claude/settings.local.json +.claude/**/*.local.* diff --git a/CLAUDE.md b/CLAUDE.md index e15c7cb..dd844b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ A Claude Code plugin marketplace (`samarth`). Each plugin lives in `plugins/`, `claude plugin test plugins/` (if it has tests) and `node --experimental-strip-types scripts/catalog.mts --check`. +- Before reporting work done, run `claude plugin validate --strict .`, `claude plugin validate --strict plugins/`, `claude plugin test plugins/` (if it has tests) and `node --experimental-strip-types scripts/catalog.mts --check`. - Before writing a mod, load the `plugin-authoring` skill. - Guides in `docs/` summarize the official docs at code.claude.com. Link to the source and don't state behavior you haven't checked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 690bea7..25ef887 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,8 @@ If you edit the marketplace by hand, run `node --experimental-strip-types script ## Try it ```bash -claude plugin validate . # the marketplace -claude plugin validate plugins/ # the plugin +claude plugin validate --strict . # the marketplace +claude plugin validate --strict plugins/ # the plugin claude plugin test plugins/ # its tests (mods) claude --plugin-dir plugins/ # a session with it loaded; mods hot-reload on save ``` @@ -77,4 +77,4 @@ Never change a plugin's `name`, because it is the install id. Change `displayNam ## Commits -Commit as `Samarth Rayar `. Make one change per commit. Start the subject with the plugin name for plugin changes (`buddy: ...`) and describe the area for repo-wide changes (`Release workflow: ...`). +Commit as `Samarth Rayar `. Make one change per commit. Start the subject with the plugin name for plugin changes (`: ...`) and describe the area for repo-wide changes (`Release workflow: ...`). diff --git a/README.md b/README.md index 4fc85a9..d1adbfa 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Installed plugins also load in the Claude desktop app (Code tab, local sessions) | Plugin | Install id | Type | What it does | | --- | --- | --- | --- | -| [Buddy](plugins/buddy) | `buddy` | mods | A pixel-art companion above the prompt that acts out what Claude is doing, with a sprout that wilts as context fills, a context sparkline, usage limits and cost | +| [HUD](plugins/hud) | `hud` | mods | A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs and a dashboard pane | Each plugin has its own README covering what it adds, how to use and configure it, and its changelog. This table is generated from [`marketplace.json`](.claude-plugin/marketplace.json) by `scripts/catalog.mts`. diff --git a/plugins/buddy/.claude-plugin/plugin.json b/plugins/buddy/.claude-plugin/plugin.json deleted file mode 100644 index 9ead42b..0000000 --- a/plugins/buddy/.claude-plugin/plugin.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "buddy", - "displayName": "Buddy", - "version": "0.3.0", - "description": "A pixel-art companion above the prompt that acts out what Claude is doing, with a sprout that wilts as context fills, a context sparkline, usage limits and cost", - "author": { "name": "Samarth Rayar", "url": "https://github.com/Samarth2001" }, - "homepage": "https://github.com/Samarth2001/claude-plugins/tree/main/plugins/buddy", - "repository": "https://github.com/Samarth2001/claude-plugins", - "license": "MIT", - "keywords": ["mod", "companion", "pixel-art", "status", "context", "usage"], - "types": "./types/index.d.ts" -} diff --git a/plugins/buddy/CHANGELOG.md b/plugins/buddy/CHANGELOG.md deleted file mode 100644 index 1379017..0000000 --- a/plugins/buddy/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog - -All notable changes to buddy. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/). Each release is tagged `buddy--v`. - -## [0.3.0] - 2026-10-01 - -### Changed - -- Redrawn as a pixel-art mochi with a sprout on its head, the same sprite on every surface: half-block cells in a terminal `Raster`, crisp rects with SMIL frame swaps in the desktop `Svg`. -- The sprout is the context gauge: green, then yellow from 50%, orange from 75% (buddy sweats), brown and wilted from 90%. -- Each mood has its own prop and motion: a firefly while ready, thought bubbles, an open book, a pencil, a tiny terminal, a spinning globe, a helper mochi hopping off, a gear, sparkles, a red `!`, floating z's. -- The band is a four-line HUD: what buddy is doing, a context sparkline with one bar per turn and the last turn's jump, usage limit meters with reset times, cost, files edited and the model. -- Session state lives in `$.state` instead of module variables, so a hot reload keeps it. - -### Added - -- `compact` button and `/buddy compact` / `/buddy full`: a two-line band with just the face; the choice is remembered across sessions. Narrow terminals switch to it on their own. -- `/buddy` toggles buddy on and off. -- The `done` line says how long the turn took and how many tools it used. - -### Fixed - -- The desktop SVG no longer draws on a white box in a dark app (`color-scheme` on the SVG root keeps its sandboxed frame transparent). -- A subagent finishing no longer ends the main turn's animation. - -## [0.2.0] - 2026-10-01 - -### Changed - -- Quiet glyph faces in the terminal and a small pebble SVG on desktop; essentials-only stats. - -## [0.1.0] - 2026-10-01 - -### Added - -- First release: an animated companion above the prompt with context, usage limits, cost and turn stats. - -[0.3.0]: https://github.com/Samarth2001/claude-plugins/compare/59fce6a...buddy--v0.3.0 -[0.2.0]: https://github.com/Samarth2001/claude-plugins/commit/59fce6a -[0.1.0]: https://github.com/Samarth2001/claude-plugins/commit/bb77862 diff --git a/plugins/buddy/README.md b/plugins/buddy/README.md deleted file mode 100644 index 5196e18..0000000 --- a/plugins/buddy/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# buddy - -A pixel-art companion that lives above the Claude Code prompt. It acts out what Claude is doing and keeps the numbers that matter for the session beside it. - -![buddy's moods](assets/moods.svg) - -Beside the sprite, four lines: - -``` -reading register.tsx · 12s compact -ctx ▁▂▂▃▄▅ 38% 380k/1M ▲42k -5h ━━━━━━── 82% resets 4m 7d ━━━━──── 45% resets 9h44m -$2.07 · 3 files edited · opus-5-5 -``` - -## Install - -```bash -claude plugin marketplace add Samarth2001/claude-plugins -claude plugin install buddy@samarth -``` - -Then run `/reload-plugins` in an open session, or start a new one. It works in the terminal, the desktop app's Code tab and VS Code. - -## What it shows - -| Line | What | -| --- | --- | -| 1 | What buddy is doing: `reading register.tsx · 12s`, `done in 1m04s · 7 tools`, `dozing · idle 5m` | -| 2 | Context: a sparkline with one bar per turn, the percent now, tokens used of the window, and how much the last turn added | -| 3 | Usage limits (5-hour, 7-day) as meters, with the time until each resets | -| 4 | Session cost, files edited this session, tools this turn, the model | - -### Moods - -| Mood | When | Prop | -| --- | --- | --- | -| ready | waiting for you | a firefly drifting by | -| thinking | a turn is running, no tool yet | thought bubbles | -| reading | Read, Grep, Glob | an open book | -| editing | Edit, Write, NotebookEdit | a pencil | -| running | Bash, PowerShell | a tiny terminal | -| browsing | WebFetch, WebSearch | a spinning globe | -| delegating | a subagent | a helper mochi hopping off | -| using a tool | any other tool, MCP tools included | a gear | -| done | for 4 seconds after a turn | sparkles, happy eyes | -| oops | for 2.5 seconds after a tool error | a red `!`, crossed eyes | -| dozing | nothing for 2 minutes | floating z's | -| stuffed | context at 90% or more | wilted sprout, a hint to `/compact` | - -The sprout on its head is the context gauge: green, then yellow from 50%, orange from 75% (buddy starts to sweat), and brown and wilted from 90%. The numbers beside it use the same colors. - -## Controls - -| Control | Does | -| --- | --- | -| `compact` / `expand` | Switch between the four-line band and a two-line band with just the face. Remembered across sessions | -| `/buddy` | Hide buddy, or bring it back | -| `/buddy compact`, `/buddy full` | Pick the size | -| `[-]` (terminal) or `hide` (desktop) | Collapse the band for now | - -Terminals narrower than 64 columns get the compact band on their own. - -## How it works - -One 19x8 pixel grid per animation frame, drawn by [`hooks/sprite.ts`](hooks/sprite.ts): - -- **Terminal**: a `Raster` of half-block cells (two pixels a cell), redrawn at each mood's frame rate. -- **Desktop and VS Code**: an `Svg` with every frame as a group of rects and a discrete SMIL loop between them, so it animates with no redraws. `color-scheme: light dark` on the SVG root keeps its sandboxed frame transparent. - -Data comes from `$.session.usage()`, `session.measure`, `turn.start`, `tool.call`, `turn.complete` and `$.session.model()`. Everything drawn is kept in `$.state`, so a hot reload keeps it; the compact choice is kept in `$.store`. - -## Develop - -From the repository root: - -```bash -claude plugin validate plugins/buddy -claude plugin test plugins/buddy -claude --plugin-dir plugins/buddy # try it, hot-reloads on save -node --experimental-strip-types scripts/assets/buddy-moods.mts # after changing the sprite -``` - -See [CHANGELOG.md](CHANGELOG.md) for releases. diff --git a/plugins/buddy/assets/moods.svg b/plugins/buddy/assets/moods.svg deleted file mode 100644 index 3ce6be2..0000000 --- a/plugins/buddy/assets/moods.svg +++ /dev/null @@ -1 +0,0 @@ -readythinkingreadingeditingrunningbrowsingdelegatingusing a tooldonedozingoopsstuffed (93%) diff --git a/plugins/buddy/hooks/format.ts b/plugins/buddy/hooks/format.ts deleted file mode 100644 index 2697c21..0000000 --- a/plugins/buddy/hooks/format.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Pure helpers: what a tool call looks like to buddy, and how numbers print. - -import type { Activity } from '../types' - -export function basename(path: string): string { - return path.split(/[\\/]/).filter(Boolean).pop() ?? path -} - -// A tool call as a mood, a verb and what it acts on. -export function activityOf(tool: string, input: Record): Activity { - const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined) - const path = text('file_path') ?? text('notebook_path') ?? text('path') - - switch (tool) { - case 'Read': - return { mood: 'reading', verb: 'reading', target: path && basename(path) } - case 'Grep': - case 'Glob': - return { mood: 'reading', verb: 'searching', target: text('pattern') } - case 'Edit': - case 'MultiEdit': - case 'NotebookEdit': - return { mood: 'editing', verb: 'editing', target: path && basename(path) } - case 'Write': - return { mood: 'editing', verb: 'writing', target: path && basename(path) } - case 'Bash': - case 'PowerShell': - return { mood: 'running', verb: 'running', target: text('command')?.split(/\s+/).slice(0, 3).join(' ') } - case 'WebFetch': - return { mood: 'browsing', verb: 'fetching', target: text('url')?.replace(/^https?:\/\//, '').split('/')[0] } - case 'WebSearch': - return { mood: 'browsing', verb: 'searching the web for', target: text('query') } - case 'Agent': - case 'Task': - return { mood: 'delegating', verb: 'delegating', target: text('description') } - default: - return tool.startsWith('mcp__') - ? { mood: 'working', verb: 'using', target: tool.split('__').slice(1).join(' ') } - : { mood: 'working', verb: 'using', target: tool } - } -} - -export function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k` - return `${n}` -} - -export function formatDuration(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)) - if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m` - return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s` -} - -export function formatUntil(resetsAt: string, now: number): string { - const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60_000)) - if (minutes >= 24 * 60) return `${Math.floor(minutes / 1440)}d${Math.floor((minutes % 1440) / 60)}h` - if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` - return `${minutes}m` -} - -const BARS = '▁▂▃▄▅▆▇█' - -// One bar per reading, 0-100%. -export function spark(percent: number): string { - const i = Math.round((Math.min(100, Math.max(0, percent)) / 100) * (BARS.length - 1)) - return BARS[i] ?? BARS[0] ?? '' -} - -export function meter(percent: number, cells: number): { filled: string; empty: string } { - const n = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells) - return { filled: '━'.repeat(n), empty: '─'.repeat(cells - n) } -} - -export function shorten(text: string, max: number): string { - return text.length > max ? `${text.slice(0, max - 1)}…` : text -} diff --git a/plugins/buddy/hooks/hooks.json b/plugins/buddy/hooks/hooks.json deleted file mode 100644 index 5a63f64..0000000 --- a/plugins/buddy/hooks/hooks.json +++ /dev/null @@ -1 +0,0 @@ -{ "modules": ["./register.tsx"] } diff --git a/plugins/buddy/hooks/register.test.tsx b/plugins/buddy/hooks/register.test.tsx deleted file mode 100644 index b308659..0000000 --- a/plugins/buddy/hooks/register.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, mock, test } from 'claude-code/testing' -import type { On } from 'claude-code' - -const SURFACES = ['terminal', 'desktop'] as const - -const BAND = (isWorking: boolean, bodyColumns = 160, maxRows = 10) => - ({ - plugin: 'buddy', - component: 'AbovePrompt', - props: { hasSurvey: false, isWorking, maxRows, bodyColumns, scroll: { offset: 0, bodyRows: 10 }, view: {} }, - }) as const - -const usage = (tokens: number) => ({ - context: { tokens, window: 1_000_000, percent: Math.round(tokens / 10_000) }, - rateLimits: [ - { kind: 'five_hour', percentUsed: 23, resetsAt: '2099-01-01T00:00:00Z' }, - { kind: 'seven_day', percentUsed: 81 }, - ], - cost: { usd: 2.414 }, -}) - -// The engine beneath the plugin. `tokens` is read on every usage call, so a -// test can grow the context between turns. -function engine(on: On, context = { tokens: 380_000 }, store = new Map()) { - const clock = mock.clock(on) - on('session.start', () => ({ cwd: '/' })) - on('session.usage', () => ({ value: { startedAt: 0, ...usage(context.tokens) } })) - on('session.model', () => ({ value: 'claude-opus-5-5' })) - on('session.measure', (_$, e) => ({ changed: e.changed })) - on('turn.start', (_$, e) => ({ turnId: e.turnId })) - on('turn.complete', () => ({ text: '' })) - on('command.register', () => ({ value: undefined }) as never) - on('store.get', (_$, e) => ({ value: store.get(e.key) }) as never) - on('store.set', (_$, e) => { - store.set(e.key, e.value) - return { value: undefined } as never - }) - on('ui.render', { component: 'AbovePrompt' }, ($, e) => { - const { Box } = $.ui.resolve(e) - return - }) - return clock -} - -const start = { cwd: '/', surface: 'terminal', isInteractive: true } as const -const done = { answer: '', durationMs: 1, isAborted: false, turnId: 't', reason: 'answer' } as const -type Found = { text?: string; props?: Record } -const text = async (ui: { findAll: (q: { type: 'Text' }) => Promise }) => - (await ui.findAll({ type: 'Text' })).map(t => t.text ?? '').join('') - -for (const surface of SURFACES) { - test(`${surface}: idle band draws buddy and the session numbers`, async ($, on) => { - engine(on) - await $.session.start(start) - const ui = await $.ui.mount({ ...BAND(false), surface }) - const line = await text(ui) - expect(line).toContain('ready') - expect(line).toContain('38%') - expect(line).toContain('380k/1M') - expect(line).toContain('5h') - expect(line).toContain('23%') - expect(line).toContain('81%') - expect(line).toContain('$2.41') - expect(line).toContain('opus-5-5') - if (surface === 'terminal') { - const art = (await ui.find({ type: 'Raster' })) as Found | undefined - expect(art?.props?.columns).toBe(19) - expect(art?.props?.rows).toBe(4) - } else { - const art = (await ui.find({ type: 'Svg' })) as Found | undefined - const source = String(art?.props?.source) - expect(source).toContain('color-scheme:light dark') // no white box on a dark app - expect(source).toContain('calcMode="discrete"') // animates without redraws - } - }) - - test(`${surface}: acts out the running tool, then celebrates`, async ($, on) => { - let during = '' - on('tool.call', async () => { - const ui = await $.ui.mount({ ...BAND(true), surface }) - during = await text(ui) - await ui.unmount() - return { result: 'ok' } as never - }) - engine(on) - await $.session.start(start) - await $.turn.start({ text: 'go', turnId: 't' }) - await $.tool.call({ tool: 'Edit', file_path: '/repo/src/app.ts', old_string: 'a', new_string: 'b' } as never) - expect(during).toContain('editing') - expect(during).toContain('app.ts') - expect(during).toContain('1 tools') - - await $.turn.complete(done) - const after = await $.ui.mount({ ...BAND(false), surface }) - const line = await text(after) - expect(line).toContain('done') - expect(line).toContain('1 tool') - expect(line).toContain('1 file edited') - }) - - test(`${surface}: a failed tool says oops`, async ($, on) => { - on('tool.call', () => ({ result: 'boom', isError: true }) as never) - engine(on) - await $.session.start(start) - await $.turn.start({ text: 'go', turnId: 't' }) - await $.tool.call({ tool: 'Bash', command: 'false' } as never) - const ui = await $.ui.mount({ ...BAND(true), surface }) - expect(await text(ui)).toContain('oops') - }) - - test(`${surface}: compact button shrinks it to the face and remembers`, async ($, on) => { - const store = new Map() - engine(on, undefined, store) - await $.session.start(start) - const ui = await $.ui.mount({ ...BAND(false), surface }) - await ui.press({ key: 'size' }) - const line = await text(ui) - expect(line).toContain('38%') - expect(line).not.toContain('opus-5-5') - expect(store.get('isCompact')).toBe(true) // remembered for the next session - if (surface === 'terminal') { - const art = (await ui.find({ type: 'Raster' })) as Found | undefined - expect(art?.props?.rows).toBe(2) - } - }) -} - -test('the terminal sprite animates while working', async ($, on) => { - const clock = engine(on) - await $.session.start(start) - await $.turn.start({ text: 'go', turnId: 't' }) - const ui = await $.ui.mount({ ...BAND(true), surface: 'terminal' }) - const cellsAt = async () => ((await ui.find({ type: 'Raster' })) as Found | undefined)?.props?.cells - const before = await cellsAt() - await clock.advance(400) - expect(await cellsAt()).not.toBe(before) -}) - -test('the sparkline grows a bar per turn and shows the jump', async ($, on) => { - const context = { tokens: 100_000 } - engine(on, context) - await $.session.start(start) - for (const tokens of [100_000, 300_000, 600_000]) { - context.tokens = tokens - await $.turn.start({ text: 'go', turnId: 't' }) - await $.turn.complete(done) - } - const ui = await $.ui.mount({ ...BAND(false), surface: 'terminal' }) - const line = await text(ui) - expect(line).toMatch(/ctx [▁-█]{4} 60%/) - expect(line).toContain('▲300k') -}) - -test('near-full context wilts the sprout and asks for /compact', async ($, on) => { - engine(on, { tokens: 920_000 }) - await $.session.start(start) - const ui = await $.ui.mount({ ...BAND(false), surface: 'terminal' }) - const line = await text(ui) - expect(line).toContain('stuffed') - expect(line).toContain('/compact') -}) - -test('a subagent finishing does not end our turn', async ($, on) => { - engine(on) - await $.session.start(start) - await $.turn.start({ text: 'go', turnId: 't' }) - await $.turn.complete({ ...done, agentId: 'sub' } as never) - const ui = await $.ui.mount({ ...BAND(false), surface: 'terminal' }) - expect(await text(ui)).not.toContain('done') -}) - -test('/buddy hides and brings it back', async ($, on) => { - engine(on) - await $.session.start(start) - const run = (args: string) => $.command.run({ command: 'buddy', args } as never) - expect((await run('')) as { text?: string }).toMatchObject({ text: expect.stringContaining('hidden') }) - const hidden = await $.ui.mount({ ...BAND(false), surface: 'terminal' }) - expect(await text(hidden)).not.toContain('ready') - await hidden.unmount() - await run('') - const shown = await $.ui.mount({ ...BAND(false), surface: 'terminal' }) - expect(await text(shown)).toContain('ready') -}) - -test('a narrow terminal falls back to the compact face', async ($, on) => { - engine(on) - await $.session.start(start) - const ui = await $.ui.mount({ ...BAND(false, 60), surface: 'terminal' }) - const art = (await ui.find({ type: 'Raster' })) as Found | undefined - expect(art?.props?.rows).toBe(2) - expect(await text(ui)).toContain('ready') -}) diff --git a/plugins/buddy/hooks/register.tsx b/plugins/buddy/hooks/register.tsx deleted file mode 100644 index b8d760f..0000000 --- a/plugins/buddy/hooks/register.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { atom, read, update } from 'claude-code' -import type { Register, RenderElement, SessionUsage } from 'claude-code' - -import type { Limit, Live, Mood, Reading, Snapshot } from '../types' -import { activityOf, formatDuration, formatTokens, formatUntil, meter, shorten, spark } from './format' -import { FACE, HEIGHT, WIDTH, animation, cells, frameAt, levelColor, svg } from './sprite' - -const IDLE: Live = { - turnStartedAt: 0, - lastActiveAt: 0, - toolsThisTurn: 0, - doneUntil: 0, - errorUntil: 0, - activity: null, - lastTurn: null, - model: '', - files: [], -} - -const snapshot = atom({ plugin: 'buddy', key: 'snapshot' } as const, null) -const readings = atom({ plugin: 'buddy', key: 'readings' } as const, []) -const live = atom({ plugin: 'buddy', key: 'live' } as const, IDLE) -const isHidden = atom({ plugin: 'buddy', key: 'isHidden' } as const, false) -const isCompact = atom({ plugin: 'buddy', key: 'isCompact' } as const, false) - -const COMPACT_KEY = 'isCompact' // in $.store, so the choice outlives the session -const DONE_MS = 4000 -const ERROR_MS = 2500 -const SLEEP_AFTER_MS = 2 * 60_000 -const HISTORY = 16 -const TICK_MS = 120 -const NARROW_COLUMNS = 100 -const TINY_COLUMNS = 64 -const DESKTOP_SCALE = 6 -const LIMIT_LABELS: Record = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } - -const MOOD_COLOR: Record = { - idle: '#9AA3B5', - sleeping: '#9AA3B5', - thinking: '#B4A7F5', - reading: '#7FB2F0', - editing: '#F2C14E', - running: '#7BC96F', - browsing: '#5FB8D9', - delegating: '#E59AC4', - working: '#C9A2F2', - done: '#F2C14E', - error: '#E5534B', - full: '#E8964A', -} - -function toSnapshot(usage: Pick): Snapshot { - return { - percent: usage.context.percent, - tokens: usage.context.tokens, - window: usage.context.window, - usd: usage.cost?.usd, - limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }): Limit => ({ kind, percentUsed, resetsAt })), - } -} - -export const register: Register = on => { - // Redraw pacing only; everything drawn comes from $.state. - let pace = 250 - let sinceDraw = 0 - - on('session.start', async ($, e, next) => { - const result = await next(e) - await $.command.register({ - name: 'buddy', - description: 'Show or hide buddy; "/buddy compact" or "/buddy full" picks its size', - }) - const usage = await $.session.usage() - await update($, snapshot, () => toSnapshot(usage)) - const model = await $.session.model() - const now = await $.clock.now() - await update($, live, l => ({ ...l, model, lastActiveAt: now })) - if ((await $.store.get(COMPACT_KEY)) === true) await update($, isCompact, () => true) - - $.clock.every(TICK_MS, () => { - sinceDraw += TICK_MS - if (sinceDraw >= pace) { - sinceDraw = 0 - $.ui.invalidate('ui.render') - } - }) - - return result - }) - - on('command.run', { command: 'buddy' }, async ($, e) => { - const arg = e.args.trim().toLowerCase() - if (arg === 'compact' || arg === 'full') { - const small = arg === 'compact' - await update($, isCompact, () => small) - await update($, isHidden, () => false) - await $.store.set(COMPACT_KEY, small) - return { text: `buddy is ${arg} now.` } - } - const hidden = !(await read($, isHidden)) - await update($, isHidden, () => hidden) - return { text: hidden ? 'buddy is hidden. Run /buddy to bring it back.' : 'buddy is back.' } - }) - - on('session.measure', async ($, e, next) => { - await update($, snapshot, () => toSnapshot(e)) - - return next(e) - }) - - on('turn.start', async ($, e, next) => { - const now = await $.clock.now() - await update($, live, l => ({ ...l, turnStartedAt: now, lastActiveAt: now, toolsThisTurn: 0, doneUntil: 0, errorUntil: 0, activity: null })) - - return next(e) - }) - - on('tool.call', async ($, e, next) => { - const input = e as unknown as Record - const current = activityOf(e.tool, input) - const path = typeof input.file_path === 'string' ? input.file_path : undefined - await update($, live, l => ({ - ...l, - activity: current, - toolsThisTurn: l.toolsThisTurn + 1, - files: current.mood === 'editing' && path && !l.files.includes(path) ? [...l.files, path] : l.files, - })) - - const result = await next(e) - const failed = result.deny === undefined && result.isError === true - const now = await $.clock.now() - await update($, live, l => ({ - ...l, - activity: l.activity === current || l.activity?.verb === current.verb ? null : l.activity, - errorUntil: failed ? now + ERROR_MS : l.errorUntil, - })) - - return result - }) - - on('turn.complete', async ($, e, next) => { - const result = await next(e) - if (e.agentId !== undefined) return result // a subagent's turn, not ours - - const now = await $.clock.now() - const model = await $.session.model() - const usage = await $.session.usage() - await update($, snapshot, () => toSnapshot(usage)) - const reading: Reading = { percent: usage.context.percent ?? 0, tokens: usage.context.tokens ?? 0 } - await update($, readings, list => [...list, reading].slice(-HISTORY)) - await update($, live, l => ({ - ...l, - activity: null, - model, - lastActiveAt: now, - doneUntil: e.isAborted ? 0 : now + DONE_MS, - lastTurn: { ms: now - l.turnStartedAt, tools: l.toolsThisTurn }, - })) - - return result - }) - - on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { - const snap = await read($, snapshot) - if (e.props.hasSurvey || snap === null || (await read($, isHidden)) || e.props.maxRows < 2) { - return next(e) - } - - const l = await read($, live) - const history = await read($, readings) - const now = await $.clock.now() - const isWorking = e.props.isWorking - const percent = snap.percent ?? 0 - - // What buddy is doing, and the line that says it. - let mood: Mood - const status: RenderElement[] = [] - const { Box, Button, Text } = $.ui.resolve(e) - const dim = (text: string) => {text} - const say = (m: Mood, word: string) => {word} - - if (l.errorUntil > now) { - mood = 'error' - status.push(say(mood, 'oops'), dim(' that tool call failed')) - } else if (isWorking) { - mood = l.activity?.mood ?? 'thinking' - status.push(say(mood, l.activity?.verb ?? 'thinking')) - if (l.activity?.target) status.push( {shorten(l.activity.target, 32)}) - status.push(dim(` · ${formatDuration(now - l.turnStartedAt)}`)) - } else if (l.doneUntil > now) { - mood = 'done' - status.push(say(mood, 'done')) - if (l.lastTurn) status.push(dim(` in ${formatDuration(l.lastTurn.ms)} · ${l.lastTurn.tools} tool${l.lastTurn.tools === 1 ? '' : 's'}`)) - } else if (percent >= 90) { - mood = 'full' - status.push(say(mood, 'stuffed'), dim(' context nearly full, try /compact')) - } else if (l.lastActiveAt > 0 && now - l.lastActiveAt > SLEEP_AFTER_MS) { - mood = 'sleeping' - status.push(say(mood, 'dozing'), dim(` · idle ${formatDuration(now - l.lastActiveAt)}`)) - } else { - mood = 'idle' - status.push(say(mood, 'ready')) - if (l.lastTurn) status.push(dim(` · last turn ${formatDuration(l.lastTurn.ms)}`)) - } - - const columns = e.props.bodyColumns - const isSmall = (await read($, isCompact)) || columns < TINY_COLUMNS || e.props.maxRows < Math.ceil(HEIGHT / 2) - const isNarrow = columns < NARROW_COLUMNS - const loop = animation(mood, { percent, isSweating: percent >= 75 && percent < 90 }) - pace = e.surface === 'terminal' ? loop.frameMs : 1000 - - // Context: a sparkline of past turns, then now. - const isMeasured = snap.percent !== undefined - const sparks = isMeasured ? [...history.slice(isNarrow ? -6 : -12).map(r => r.percent), percent] : [] - const context: RenderElement[] = [dim('ctx ')] - for (const p of sparks) context.push({spark(p)}) - context.push(isMeasured ? {percent}% : dim('measured after the first turn')) - if (!isNarrow && snap.tokens !== undefined) context.push(dim(` ${formatTokens(snap.tokens)}/${formatTokens(snap.window)}`)) - const last = history.at(-1) - const before = history.at(-2) - if (!isNarrow && last && before && last.tokens !== before.tokens) { - const delta = last.tokens - before.tokens - context.push( 0 ? undefined : '#7BC96F'} dimColor={delta > 0}> {delta > 0 ? '▲' : '▼'}{formatTokens(Math.abs(delta))}) - } - - const limit = (one: Limit, cellsWide: number) => { - const { filled, empty } = meter(one.percentUsed, cellsWide) - const out: RenderElement[] = [dim(`${LIMIT_LABELS[one.kind] ?? one.kind} `)] - if (cellsWide > 0) out.push({filled}, dim(empty), ) - out.push({one.percentUsed}%) - if (!isNarrow && one.resetsAt) out.push(dim(` resets ${formatUntil(one.resetsAt, now)}`)) - return out - } - - const toggle = ( -