Counts which tools Claude calls in this project, puts the unused ones behind ToolSearch, lists the daily ones up front, and reports how many schema tokens each…

Small cards that sit under the Claude Code prompt, or docked beside the transcript in fullscreen. Each widget is its own mod, toggled with a slash command.
It started with a few useful ones (context, usage, file tree) and then got carried away: there are now 95 widgets, from git status and a turn timer to a pixel crab, a dungeon crawl and Tetris.
Try every widget in its own little terminal on the demo page: the real widget code runs in the browser against a simulated session, so you can type its slash commands, run a turn and play the games. It is a static page in docs/, published to GitHub Pages on every push to main that changes it; opening docs/index.html from a checkout works too.
Add the marketplace, then install the layout plugin and whichever widgets you want:
/plugin marketplace add oMaN-Rod/claude-code-widgets
/plugin install widgets@claude-code-widgets
/plugin install context-widget@claude-code-widgets
Every widget needs widgets, the layout plugin that places the cards.
Requires a Claude Code version with mod (function hook) support. The mod API is early access and may change between releases.
Required by every widget.
| Plugin | Command | What it shows | ||||||
|---|---|---|---|---|---|---|---|---|
widgets | `/widgets [side\ | above\ | below\ | close\ | <columns>\ | width <widget> <columns\ | reset>]` | Places the cards: below the prompt, above it, or docked beside the transcript in fullscreen |
What Claude is doing and what it is costing.
| Plugin | Command | What it shows | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
context-widget | `/context-widget [auto\ | detailed\ | grid\ | top\ | bar\ | line]` | The context window as a stacked bar, one colour per /context category | |||
usage-widget | /usage-widget | A bar per rate-limit window, time to reset, session cost | ||||||||
timer-widget | `/timer-widget [on\ | off\ | clear]` | The running turn's elapsed time, plus last, average and longest turn and a sparkline | ||||||
activity-widget | `/activity-widget [on\ | off\ | clear]` | The latest tool calls with their duration and whether they failed | ||||||
tasks-widget | `/tasks-widget [on\ | off\ | clear]` | The task list Claude is working through, with a progress bar | ||||||
checks-widget | `/checks-widget [on\ | off\ | clear]` | The latest test, lint and build runs: pass or fail, duration and how long ago | ||||||
timeline-widget | `/timeline-widget [on\ | off]` | The turn as a chart: one bar per tool call on a time axis, parallel calls stacked | |||||||
board-widget | `/board-widget [on\ | off\ | clear]` | A status board Claude writes itself through a pin tool: goal, findings and open questions | ||||||
forecast-widget | `/forecast-widget [on\ | off\ | clear]` | Context growth per turn as a chart, with the turns left before compaction; warns once when three or fewer are left | ||||||
guard-widget | `/guard-widget [on\ | off\ | clear]` | Permission checks this session: allowed, asked and denied, with what you keep being asked about | ||||||
stream-widget | `/stream-widget [on\ | off]` | A live tokens-per-second gauge and sparkline while Claude is writing (estimated from text length) | |||||||
sessions-widget | /relay <number> <message>, `/sessions-widget [on\ | off]` | Every Claude Code session open on this machine: folder, branch, working or waiting and for how long; /relay 1 <message> sends a line to one | |||||||
collision-widget | `/collision-widget [on\ | off\ | clear]` | Stops an edit to a file another Claude Code session on this machine changed since this one last read it, and has Claude read it again first | ||||||
sieve-widget | `/sieve-widget [on\ | off]` | A compaction that keeps every word you and Claude said and folds only the old tool traffic, with no summary and no tokens spent | |||||||
redact-widget | `/redact-widget [on\ | off\ | clear]` | Replaces API keys, tokens, private keys and passwords in command output and file reads before Claude sees them | ||||||
notebook-widget | `/notebook-widget [on\ | off\ | show\ | drop <number>\ | clear]` | Gives Claude a jot tool for writing down what its future self should know about this project, and reads the notes back next session | ||||
footnotes-widget | `/footnotes-widget [on\ | off\ | show\ | clear]` | Checks every file path, cited line and code symbol in Claude's last reply against the working tree, with no model call, and flags the ones that are not there | |||||
witness-widget | `/witness-widget [on\ | off\ | show\ | clear]` | Every line of hidden context the other widgets add to your prompts, word for word, with a running size | |||||
queue-widget | `/queue-widget [on\ | off\ | add <prompt>\ | until <command\ | off>\ | start\ | drop <number>\ | report\ | clear]` | A queue of prompts that run back to back while you are away, a gate command each must pass before the next starts, and a report of how each one ended |
done-widget | `/done-widget [on\ | off\ | add <criterion>\ | drop <number>\ | show\ | clear]` | A definition of done you write once: Claude ticks each item with evidence through its tick tool, and is pulled up when it says done with items open | |||
trial-widget | `/trial-widget [on\ | off\ | test <widget>\ | clear]` | A fair test of another widget: sessions alternate with it on and off, and the card compares how many turns end clean | |||||
aside-widget | `/aside-widget [on\ | off\ | ask <question>\ | clear]` | Ask a side question about the conversation, even mid-turn, and get the answer on a card without adding a turn to the transcript | |||||
tap-widget | `/tap-widget [on\ | off\ | list [word]\ | add <server> <tool> [json] [anyway]\ | run <n>\ | show <n>\ | drop <n>\ | clear]` | One line per tool of your connected MCP servers, called by the widget on a clock with no model: pull requests, errors, the next meeting | |
margin-widget | `/margin-widget [on\ | off\ | mark [remark]\ | drop <n>\ | send\ | clear]` | Mark passages of Claude's reply with the mouse, write a remark against each, and send them back as one quoted prompt | |||
loupe-widget | `/loupe-widget [on\ | off\ | look [text]\ | copy]` | Select a hash, path, name, timestamp or colour in the transcript with the mouse and the card says what it is, with no model call | |||||
strays-widget | `/strays-widget [on\ | off\ | stop <port>\ | forget <port>\ | clear]` | Servers this session started that still hold a port, with age and turn, one verb to stop one, and the survivors shown when the next session opens | ||||
premise-widget | `/premise-widget [on\ | off\ | show\ | fix <n>\ | clear]` | Quotes thinking-summary sentences that name a gap and a choice made anyway, unless the reply flags it, and starts your correction; no model call; needs "showThinkingSummaries": true in settings.json | ||||
earpiece-widget | `/earpiece-widget [on\ | off], /whisper <number> <note>` | Every running subagent with the tool it is on and its last sentence, and /whisper slips one of them a note without stopping the turn | |||||||
pen-widget | `/pen-widget [on\ | off\ | show\ | clear]` | The file, edit or command Claude is writing right now, drawn line by line as the tool call's arguments stream in, before the tool runs | |||||
outage-widget | `/outage-widget [on\ | off\ | check\ | clear]` | When a push or install fails on the network, checks the provider's status page and says whether GitHub, npm, PyPI or crates.io reports an incident | |||||
landmarks-widget | `/landmarks-widget [on\ | off\ | list\ | go <n>\ | clear]` | A numbered table of contents for the session, built as it happens from prompts, first edits, checks turning red or green, commits and questions; press a line to scroll the transcript to it | ||||
seen-widget | `/seen-widget [on\ | off\ | show\ | open [n]\ | clear]` | The pictures Claude looked at this session, image files it read and screenshots that came back from tools, drawn as Claude received them and opened full size with one command | ||||
amendments-widget | `/amendments-widget [on\ | off\ | show [n]\ | clear]` | What changed in the system prompt and the built-in tool descriptions Claude Code gives Claude since the version you last ran, with the before and after one command away | |||||
skimmed-widget | `/skimmed-widget [on\ | off\ | show\ | clear]` | The caveats in Claude's replies that left your screen while Claude was still writing and have not been back, quoted in full; it never claims that what was on screen was read | |||||
attic-widget | `/attic-widget [on\ | off\ | show\ | keep <tool>\ | stow <tool>\ | clear]` | Counts which tools Claude calls in this project, puts the unused ones behind ToolSearch, lists the daily ones up front, and reports how many schema tokens each request no longer carries | |||
rehearsal-widget | `/rehearsal-widget [on\ | off\ | show\ | forget <n>\ | clear]` | Which of the commands and edits Claude has made in this project would stop for permission or be refused right now, asked of the engine's own permission check without running anything | ||||
aim-widget | `/aim-widget [on\ | off\ | prod <word>\ | unprod <word>\ | show\ | clear]` | Where a command lands outside this folder: the kube context, cloud profile, Terraform workspace or database it is aimed at, named in the permission dialog | |||
earshot-widget | `/earshot-widget [on\ | off\ | last\ | clear]` | Whether Claude has got the message you typed while it was working: how late a request first carried it, and what Claude changed before it heard you |
The state of the working tree.
| Plugin | Command | What it shows | |||||
|---|---|---|---|---|---|---|---|
file-tree-widget | /file-tree-widget | The project directory; folders expand on click | |||||
git-widget | /git-widget | Branch, ahead/behind, staged, changed and untracked counts, and the last commit | |||||
changes-widget | `/changes-widget [on\ | off\ | clear]` | Files edited this session, most recent first, with an edit count each | |||
commits-widget | /commits-widget | The commits made since the session started | |||||
todos-widget | /todos-widget | TODO, FIXME, HACK and XXX comments in tracked files, counted and listed | |||||
diff-widget | `/diff-widget [on\ | off\ | clear]` | The last edit as a syntax-highlighted diff | |||
watch-widget | `/watch-widget [on\ | off\ | run\ | stop], /watch <command>` | /watch bun test reruns the command after every edit: a pass or fail light and the last failing lines | ||
map-widget | `/map-widget [on\ | off\ | clear]` | A pixel map of the tracked files, lit blue where Claude has read and green where it has edited | |||
stakes-widget | `/stakes-widget [on\ | off\ | clear]` | What a yes would lose, measured from git and written under the permission dialog for a destructive command | |||
ledger-widget | `/ledger-widget [on\ | off\ | scan\ | show\ | clear]` | What a project has cost across every session, from any folder or worktree in it: measured as each turn ends, and read back from the sessions Claude Code saved | |
squiggle-widget | `/squiggle-widget [on\ | off\ | check [text]]` | Underlines file names that do not exist, as you type them in the prompt box, and paints the ones that do green | |||
critic-widget | `/critic-widget [on\ | off\ | review\ | tell\ | clear]` | A second pair of eyes: a separate model reads the uncommitted diff and lists only real defects, which you can hand to Claude | |
customs-widget | `/customs-widget [on\ | off\ | show\ | trust <name>\ | clear]` | Looks up every package Claude installs on npm or PyPI before the command runs, and holds the install for a yes when the name is missing or under 30 days old. Scoped names and installs that name a registry, or sit beside a project .npmrc that does, are not looked up; user-level and environment registry settings, bunfig.toml, .yarnrc.yml and pip and uv config files are not seen | |
provenance-widget | `/provenance-widget [on\ | off\ | scan\ | look [<path>:<line>]\ | copy\ | clear]` | The conversation behind a line of code: for lines Claude is about to edit, or a path:line you ask about, the prompt you typed before they were written and the command that resumes that session |
green-widget | `/green-widget [on\ | off\ | tell\ | clear]` | What changed in the working tree since each test, lint or build command last passed, measured against a hidden git snapshot taken at that moment and handed to Claude on request |
Clocks, timers and reminders that do not depend on the session.
| Plugin | Command | What it shows | |||||
|---|---|---|---|---|---|---|---|
pomodoro-widget | `/pomodoro-widget [on\ | off\ | start\ | break\ | stop\ | <minutes>]` | A focus timer with a countdown, a progress bar and a count of finished sessions |
countdown-widget | `/countdown-widget [on\ | off\ | clear], /countdown <HH:MM\ | <n>m\ | <n>h> [label]` | Time left to a deadline you set, with a progress bar and a toast when it arrives | |
clocks-widget | `/clocks-widget [on\ | off\ | add <zone>\ | remove <zone>\ | clear]` | Your local time beside the time zones you add, such as Asia/Tokyo | |
notes-widget | `/notes-widget [on\ | off\ | clear], /note <text\ | done <n>>` | Pinned notes kept across sessions; /note <text> adds one, /note done <n> removes it | ||
coffee-widget | `/coffee-widget [on\ | off\ | refill\ | <minutes>]` | A cup that empties over 90 minutes and nudges you to take a break |
Pixel art that reacts to turns, tool calls, checks and context usage.
| Plugin | Command | What it shows | |||
|---|---|---|---|---|---|
pet-widget | `/pet-widget [on\ | off\ | <mood>]` | Clawd, a pixel crab: works during a turn, dizzy when a tool call fails, happy when tests pass. Earns XP and levels up across sessions, wears a hat once badges-widget has awarded a badge, and with two sessions open stays in the one you last prompted. Remembers each project and greets you with how long you were away and whether the checks were red | |
aquarium-widget | `/aquarium-widget [on\ | off\ | demo]` | A fish for every running tool call and agent | |
skyline-widget | `/skyline-widget [on\ | off\ | demo\ | clear]` | One building per turn, one floor per tool call |
train-widget | `/train-widget [on\ | off]` | A locomotive pulling one wagon per tool call this turn, coloured by tool | ||
garden-widget | `/garden-widget [on\ | off\ | reset]` | A plant: a leaf per tool call, a flower when checks pass, wilting when a call fails | |
campfire-widget | `/campfire-widget [on\ | off\ | stoke]` | A campfire that burns higher with tool calls and dies down to embers when idle | |
constellation-widget | `/constellation-widget [on\ | off\ | clear]` | A star per turn, joined into a constellation that gets a name after nine | |
fireworks-widget | `/fireworks-widget [on\ | off\ | demo]` | A night sky that sets off fireworks when checks pass, and a dud when they fail | |
invaders-widget | `/invaders-widget [on\ | off\ | demo\ | clear]` | An invader arrives for every failing check; passing checks shoot them down |
weather-widget | `/weather-widget [on\ | off\ | live\ | <percent>]` | Clear sky when context has room, a storm near compaction |
boss-widget | `/boss-widget [on\ | off]` | Context usage as a boss health bar; compaction defeats it and starts the next level | ||
sky-widget | `/sky-widget [on\ | off]` | The sky at your local time: sun by day, moon by night, more stars the longer the session runs | ||
world-widget | `/world-widget [on\ | off\ | clear]` | One scene for everything: sky by the clock, weather by context, a tower per turn, a train of tool calls and a wandering crab | |
quest-widget | `/quest-widget [on\ | off\ | reset]` | The session as a dungeon crawl: a room per turn, a monster per tool call, loot when checks pass; the hero is kept across sessions | |
race-widget | `/race-widget [on\ | off\ | clear]` | Parallel subagents as cars on a track, one stride per tool call, with a podium as they finish | |
moon-widget | `/moon-widget [on\ | off\ | north\ | south]` | The real moon right now, drawn for your hemisphere, with a countdown to the next full moon and what it means for your deploy |
Abstract pictures driven by tool activity.
| Plugin | Command | What it shows | |||
|---|---|---|---|---|---|
mosaic-widget | `/mosaic-widget [on\ | off\ | clear]` | One tile per tool call, coloured by tool; failures are red | |
sorting-widget | `/sorting-widget [on\ | off\ | step]` | A bar chart being sorted, one swap per tool call | |
equalizer-widget | `/equalizer-widget [on\ | off]` | Level meters that jump with each tool call and fall back to rest | ||
rain-widget | `/rain-widget [on\ | off]` | Falling code rain that speeds up the busier the turn gets | ||
orbit-widget | `/orbit-widget [on\ | off]` | A small solar system; the planets speed up while tool calls run | ||
life-widget | `/life-widget [on\ | off\ | reset]` | Conway's Game of Life; every tool call drops a glider | |
donut-widget | `/donut-widget [on\ | off]` | The spinning 3D donut; it spins faster while tool calls run | ||
pipes-widget | `/pipes-widget [on\ | off]` | The pipes screensaver; it grows while the session is idle and pauses while Claude works | ||
maze-widget | `/maze-widget [on\ | off\ | new]` | A first-person walk through a maze seeded by the project folder | |
marquee-widget | `/marquee-widget [on\ | off\ | clear\ | <text>]` | An LED ticker scrolling session events; any other text posts your own headline |
Snake, 2048 and Tetris play themselves until you click the board and take the keys.
| Plugin | Command | What it shows | ||
|---|---|---|---|---|
snake-widget | `/snake-widget [on\ | off]` | Snake; it plays itself until you take the keys | |
2048-widget | `/2048-widget [on\ | off]` | 2048; it plays itself until you take the keys | |
tetris-widget | `/tetris-widget [on\ | off]` | Tetris; it plays itself until you take the keys | |
minesweeper-widget | `/minesweeper-widget [on\ | off]` | Minesweeper: click to reveal, right-click or f to flag, r to restart | |
breakout-widget | `/breakout-widget [on\ | off\ | reset]` | A self-playing brick breaker; every tool call adds a row of bricks |
typer-widget | `/typer-widget [on\ | off]` | A typing game: words from your own file names fall, and you type them before they land |
| Plugin | Command | What it shows | |||
|---|---|---|---|---|---|
fortune-widget | `/fortune-widget [on\ | off\ | next]` | A one-line fortune that changes with every turn | |
8ball-widget | `/8ball-widget [on\ | off], /8ball <question>` | /8ball <question> gives an answer of doubtful reliability | ||
badges-widget | `/badges-widget [on\ | off\ | reset]` | Achievements earned across sessions, with a toast when one unlocks | |
sigil-widget | `/sigil-widget [on\ | off\ | clear]` | A pixel emblem generated from this session's activity, beside a gallery of the ones from earlier sessions | |
sound-widget | `/sound-widget [on\ | off\ | mute\ | test]` | A note per tool call, a chord when checks pass, a buzz on failure, with a piano roll (audio plays on macOS only) |
Placement, which widgets are on, and view modes are saved and restored in every session.
/widgets width pet 60 sets how wide one widget's card may grow (the default is 40 columns); /widgets width pet reset puts it back./widgets side docks the cards beside the transcript only in the fullscreen layout (/tui fullscreen). In the default layout it falls back to below the prompt.sound-widget plays audio on macOS only; elsewhere it stays silent and just draws the piano roll.board-widget registers a pin tool that Claude can call, so its description is part of the context while the board is on.watch-widget runs your command through sh -c, falling back to cmd /c.Each plugin under plugins/ is a mod: a manifest, a hooks module and its tests.
claude --plugin-dir plugins # load every plugin from this checkout
claude plugin validate plugins/<name> # check a manifest and hooks module
claude plugin test plugins/<name> # run its tests
Plugin storage ($.store) is read once per session, so a widget that has to see other live sessions keeps a file under its own folder instead ($.plugin.root).
Plugins cannot import from one another, so widgets shares card stacking and the pixel renderer as $.widgets (plugins/widgets/hooks/kit.tsx), which every widget calls.
docs/ is the demo page. bun run site/build.ts bundles every widget's hooks module into docs/mods.js and writes docs/catalog.js from the tables above, so a new widget appears on the page once it has a row here. docs/engine.js is a small stand-in for the mod engine (state, store, clock, commands, a made-up project and a scripted turn), and docs/view.js draws the card trees. bun run site/smoke.ts boots every widget in that engine and runs a turn; a widget that needs an engine call the stand-in lacks shows up there. docs/widgets.js holds recordings from the real interface, shown only if a widget fails to start.
New widgets are made by the Widget Factory in factory/: a crew of Claude Code agents that takes each widget through ideation, design, build, inspection and shipping, with a different agent inspecting than built it. Clone the repository and you can run your own:
https://github.com/user-attachments/assets/6833eb82-8b2e-4b4c-80ca-4194d48d10e7
bun run --cwd factory setup # checks what you need and prepares the floor
bun run --cwd factory floor # the factory floor in your browser
bun run --cwd factory line # opens a Claude Code session that runs the line
https://github.com/user-attachments/assets/d0953871-4935-4fa9-8ab3-f58b51739a17
factory/README.md explains the stations, the crew, the widget standard and how to contribute a widget.
MIT
hooks/register.tsx 523 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginState, Register, RenderElement, RenderSurface, ToolDescribeInput, ToolDescribeResult } from 'claude-code'
3import type { WidgetsPlace } from 'widgets'
4
5import { fit, folder, plural } from './lib'
6
7type Tags = Pick<Elements[RenderSurface], 'Box' | 'Text'>
8type Book = PluginState['attic-widget']['book']
9type Plan = PluginState['attic-widget']['plan']
10type Run = PluginState['attic-widget']['run']
11type Tool = Book['tools'][string]
12type List = 'keep' | 'stow'
13type Row = { key: string; text: string; isDim: boolean; isSentence: boolean }
14
15const PANE = 'widgets'
16const CARD_COLUMNS = 40
17const CARD_FRAME = 4
18const WIDE_COLUMNS = 30
19const WINDOW = 5
20const AIRING_EVERY = 10
21const FORWARD_CALLS = 3
22const SHOW_ROWS = 20
23const SEARCH = 'ToolSearch'
24const FLOOR = [SEARCH, 'Bash', 'Read', 'Edit', 'Write', 'Grep', 'Glob']
25const USAGE = 'Usage: /attic-widget [on|off|show|keep <tool>|stow <tool>|clear]'
26const VERBS = ['', 'on', 'off', 'show', 'keep', 'stow', 'clear']
27const EMPTY = `Counting the tools Claude calls in this project. After ${WINDOW} sessions the unused ones wait behind ${SEARCH}.`
28const AIRING = 'Airing: every tool is where the engine puts it this session, so a stowed tool can earn its way back.'
29const BLIND = `No ${SEARCH} in this session, so nothing is moved. Still counting.`
30const FETCHED = 'Claude fetched '
31const REST: Book = { key: '', sessions: 0, base: -1, keep: [], stow: [], tools: {} }
32const UNPLANNED: Plan = { isReady: false, n: 1, mode: 'dry', moves: {} }
33const IDLE: Run = { isCounted: false, isDirty: false, isMeasured: false, onDemand: -1, fetched: [] }
34const site = { plugin: 'widgets', key: 'site' } as const
35const widths = { plugin: 'widgets', key: 'widths' } as const
36const restored = { plugin: 'attic-widget', key: 'isOn' } as const
37const isOn = atom({ plugin: 'attic-widget', key: 'isOn' } as const, false)
38const book = atom({ plugin: 'attic-widget', key: 'book' } as const, REST)
39const plan = atom({ plugin: 'attic-widget', key: 'plan' } as const, UNPLANNED)
40const run = atom({ plugin: 'attic-widget', key: 'run' } as const, IDLE)
41
42const cut = (text: string, room: number): string => {
43 const letters = [...text]
44
45 return letters.length <= room ? text : `${letters.slice(0, Math.max(0, room - 1)).join('')}…`
46}
47
48const wrapped = (text: string, room: number): string[] =>
49 text.split(' ').reduce<string[]>((lines, word) => {
50 const last = lines.at(-1)
51
52 return last !== undefined && last.length + 1 + word.length <= room ? [...lines.slice(0, -1), `${last} ${word}`] : [...lines, cut(word, room)]
53 }, [])
54
55const kilo = (tokens: number): string => {
56 if (tokens < 1000) return String(tokens)
57
58 const tenths = Math.round(tokens / 100)
59
60 return tenths < 1000 ? `${(tenths / 10).toFixed(1)}k` : `${Math.round(tokens / 1000)}k`
61}
62
63const calls = (tool: Tool, n: number): number => tool.used.filter(at => at >= n - WINDOW && at < n).length
64
65const planned = (held: Book, n: number, hasSearch: boolean): Plan => {
66 const mode = !hasSearch ? 'blind' : n <= WINDOW ? 'dry' : n % AIRING_EVERY === 0 ? 'airing' : 'live'
67 const moves: Plan['moves'] = {}
68 if (!hasSearch) return { isReady: true, n, mode, moves }
69
70 for (const name of held.stow) if (!FLOOR.includes(name)) moves[name] = 'away'
71 for (const name of held.keep) if (held.tools[name]?.isDeferred === true) moves[name] = 'forward'
72 if (mode !== 'live') return { isReady: true, n, mode, moves }
73
74 for (const [name, tool] of Object.entries(held.tools)) {
75 if (held.keep.includes(name) || held.stow.includes(name)) continue
76
77 const used = calls(tool, n)
78 const isKnown = tool.since <= n - WINDOW && tool.seen >= n - WINDOW
79 if (tool.isEngine && !tool.isDeferred && !FLOOR.includes(name) && isKnown && used === 0) moves[name] = 'away'
80 else if (tool.isDeferred && used >= FORWARD_CALLS) moves[name] = 'forward'
81 }
82
83 return { isReady: true, n, mode, moves }
84}
85
86const noted = (held: Book, e: ToolDescribeInput, n: number): Book => {
87 const known = held.tools[e.tool]
88 const isEngine = e.provider.plugin === 'engine'
89 const isDeferred = e.isDeferred === true
90 if (known !== undefined && known.isEngine === isEngine && known.isDeferred === isDeferred && known.seen === n) return held
91
92 return { ...held, tools: { ...held.tools, [e.tool]: { isEngine, isDeferred, since: known?.since ?? n, seen: n, used: known?.used ?? [] } } }
93}
94
95const tallied = (held: Book, tool: string, n: number): Book => {
96 const known = held.tools[tool] ?? { isEngine: false, isDeferred: false, since: n, seen: 0, used: [] }
97 if (held.sessions === n && known.used.includes(n)) return held
98
99 return { ...held, sessions: n, tools: { ...held.tools, [tool]: known.used.includes(n) ? known : { ...known, used: [...known.used, n] } } }
100}
101
102const listed = (held: Book, list: List, tool: string): Book => {
103 const keep = held.keep.filter(name => name !== tool)
104 const stow = held.stow.filter(name => name !== tool)
105 if (held[list].includes(tool)) return { ...held, keep, stow }
106
107 return { ...held, keep: list === 'keep' ? [...keep, tool] : keep, stow: list === 'stow' ? [...stow, tool] : stow }
108}
109
110const merged = (saved: Book, early: Book, n: number): Book =>
111 early.key !== ''
112 ? saved
113 : {
114 ...saved,
115 tools: {
116 ...saved.tools,
117 ...Object.fromEntries(
118 Object.entries(early.tools).map(([name, tool]) => [name, { ...tool, since: saved.tools[name]?.since ?? n, seen: n, used: saved.tools[name]?.used ?? [] }]),
119 ),
120 },
121 }
122
123const trimmed = (held: Book, n: number): Omit<Book, 'key'> => ({
124 sessions: held.sessions,
125 base: held.base,
126 keep: held.keep,
127 stow: held.stow,
128 tools: Object.fromEntries(
129 Object.entries(held.tools)
130 .filter(([name, tool]) => tool.seen >= n - WINDOW || held.keep.includes(name) || held.stow.includes(name))
131 .map(([name, tool]) => [name, { ...tool, used: tool.used.filter(at => at >= n - WINDOW) }]),
132 ),
133})
134
135const present = (held: Book, n: number): string[] => Object.keys(held.tools).filter(name => held.tools[name]?.seen === n)
136
137const idle = (held: Book, n: number): string[] =>
138 present(held, n)
139 .filter(name => {
140 const tool = held.tools[name]
141
142 return tool?.isEngine === true && !tool.isDeferred && tool.used.length === 0 && !FLOOR.includes(name)
143 })
144 .sort()
145
146const unused = (held: Book, n: number): string[] => idle(held, n).filter(name => !held.keep.includes(name) && !held.stow.includes(name))
147
148const isListed = (held: Book, name: string): boolean => {
149 const tool = held.tools[name]
150
151 return tool !== undefined && tool.seen > 0 && !tool.isDeferred
152}
153
154const titled = (key: string): string => key.split('/').at(-1) || key || '/'
155
156const going = (held: Book, made: Plan, move: Plan['moves'][string]): string[] => {
157 const here = present(held, made.n)
158
159 return Object.keys(made.moves)
160 .filter(name => made.moves[name] === move && (here.length === 0 || here.includes(name)))
161 .sort()
162}
163
164const figures = (held: Book, moved: number, onDemand: number): [wide: string, narrow: string][] => {
165 if (onDemand < 0) return []
166 if (held.base < 0 || moved === 0) return [[`On demand now: ${kilo(onDemand)} tokens`, `${kilo(onDemand)} on demand`]]
167
168 const less = onDemand - held.base
169 const size = kilo(Math.abs(less))
170 const head: [string, string] = [`On demand ${kilo(held.base)} -> ${kilo(onDemand)} tokens`, `${kilo(held.base)} -> ${kilo(onDemand)}`]
171 if (Math.abs(less) > onDemand) return [head]
172 if (less === 0) return [head, ['No change in a request', '+0/request']]
173
174 return [head, less > 0 ? [`${size} less in every request`, `-${size}/request`] : [`${size} more in every request`, `+${size}/request`]]
175}
176
177const noteOf = (held: Book, made: Plan, isWide: boolean): string => {
178 if (made.mode === 'blind') return 'no search'
179 if (made.mode === 'airing') return 'airing'
180 if (made.mode === 'dry') return isWide ? `watching ${made.n}/${WINDOW}` : `${made.n}/${WINDOW}`
181
182 const away = going(held, made, 'away').length
183 const forward = going(held, made, 'forward').length
184
185 return away === 0 && forward > 0 ? `${forward} forward` : `${away} away`
186}
187
188const drawn = (held: Book, made: Plan, ran: Run, inner: number, isWide: boolean): Row[] => {
189 const pick = (wide: string, narrow: string): string => (isWide && wide.length <= inner ? wide : narrow)
190 const said = (key: string, text: string): Row => ({ key, text, isDim: true, isSentence: true })
191 const row = (key: string, wide: string, narrow: string, isDim = false): Row => ({ key, text: pick(wide, narrow), isDim, isSentence: false })
192 const away = going(held, made, 'away').length
193 const forward = going(held, made, 'forward').length
194 const known = present(held, made.n).length
195 const never = idle(held, made.n).length
196 const latest = ran.fetched.at(-1)
197 const more = ran.fetched.length - 1
198 const counts = row(
199 'counts',
200 away === 0 ? `Nothing in the attic, ${forward} forward.` : `${plural(away, 'tool')} in the attic${forward > 0 ? `, ${forward} forward` : ''}.`,
201 `${away} away, ${forward} fwd`,
202 )
203 const rows: Row[] = []
204
205 if (latest !== undefined && isWide) {
206 const name = `${cut(latest, inner - FETCHED.length)}${more > 0 ? ` and ${more} more` : ''}`
207 rows.push({ ...said('fetch', `${FETCHED}${name} from the attic. ${held.stow.includes(latest) ? 'Stowed by you, so it stays.' : 'Listed again next session.'}`), isDim: false })
208 }
209 if (latest !== undefined && !isWide) {
210 const tail = more > 0 ? ` +${more}` : ''
211 rows.push({ key: 'fetch', text: `fetched ${cut(latest, inner - 'fetched '.length - tail.length)}${tail}`, isDim: false, isSentence: false })
212 if (held.stow.includes(latest)) rows.push({ key: 'stays', text: 'stays stowed', isDim: false, isSentence: false })
213 }
214
215 if (made.mode === 'blind') rows.push(said('said', BLIND))
216 if (made.mode === 'airing') rows.push(said('said', AIRING))
217 if (made.mode === 'dry' && away + forward === 0) rows.push(known === 0 ? said('said', EMPTY) : row('still', 'Nothing moved yet.', 'nothing moved'))
218 if (made.mode === 'live' && away + forward === 0) rows.push(row('still', 'Nothing to move this session.', 'nothing moved'))
219 if (away + forward > 0) rows.push(counts)
220 if (made.mode === 'dry' && known > 0) {
221 rows.push(row('unused', never === 0 ? 'Every tool has been called here.' : `${plural(never, 'tool')} never called here so far.`, `${never} never called`))
222 }
223 figures(held, away + forward, ran.onDemand).forEach(([wide, narrow], at) => rows.push(row(at === 0 ? 'figure' : 'change', wide, narrow, true)))
224
225 return rows
226}
227
228const reported = (held: Book, made: Plan, ran: Run): string => {
229 const name = titled(held.key)
230 const away = going(held, made, 'away')
231 const forward = going(held, made, 'forward')
232 const figure = figures(held, away.length + forward.length, ran.onDemand)
233 .map(([wide]) => wide)
234 .join(', ')
235 const rows = [
236 ...away.map(tool => `away ${tool}: ${held.stow.includes(tool) ? 'stowed by you' : `no call in the last ${WINDOW} sessions`}`),
237 ...forward.map(tool => {
238 const known = held.tools[tool]
239
240 return `forward ${tool}: ${held.keep.includes(tool) || known === undefined ? 'kept by you' : `called in ${calls(known, made.n)} of the last ${WINDOW} sessions`}`
241 }),
242 ...held.keep
243 .filter(tool => made.moves[tool] === undefined && isListed(held, tool))
244 .sort()
245 .map(tool => `kept ${tool}: kept by you`),
246 ...(made.mode === 'dry' ? unused(held, made.n).map(tool => `would go ${tool}: never called here`) : []),
247 ]
248
249 return [
250 made.mode === 'dry'
251 ? `Attic, session ${made.n} of ${WINDOW} in ${name}: watching, nothing moved.`
252 : `Attic, session ${made.n} in ${name}: ${away.length} away, ${forward.length} forward.`,
253 ...(made.mode === 'airing' ? [AIRING] : []),
254 ...(made.mode === 'blind' ? [BLIND] : []),
255 ...rows.slice(0, SHOW_ROWS),
256 ...(rows.length > SHOW_ROWS ? [`and ${rows.length - SHOW_ROWS} more`] : []),
257 ...(figure === '' ? [] : [figure]),
258 ].join('\n')
259}
260
261const show = async (
262 $: EngineInterface,
263 { Box, Text }: Tags,
264 beneath: RenderElement,
265 place: WidgetsPlace,
266 columns: number,
267): Promise<RenderElement> => {
268 if (!(await read($, isOn))) return beneath
269 if ((await $.state.get(site)).value !== place) return beneath
270
271 const width = fit((await $.state.get(widths)).value?.['attic-widget'] ?? CARD_COLUMNS, columns)
272 const inner = width - CARD_FRAME
273 const isWide = width >= WIDE_COLUMNS
274 const made = await read($, plan)
275 const held = await read($, book)
276
277 return $.widgets.card({
278 beneath,
279 width,
280 title: 'Attic',
281 note: noteOf(held, made, isWide),
282 body: (
283 <Box flexDirection="column">
284 {drawn(held, made, await read($, run), inner, isWide).flatMap(({ key, text, isDim, isSentence }) =>
285 (isSentence ? wrapped(text, inner) : [cut(text, inner)]).map((line, at) => (
286 <Box key={`${key}:${at}`}>
287 <Text dimColor={isDim} wrap="truncate-end">
288 {line}
289 </Text>
290 </Box>
291 )),
292 )}
293 </Box>
294 ),
295 })
296}
297
298const named = async ($: EngineInterface): Promise<string[]> => {
299 try {
300 return (await $.tool.list()).map(tool => tool.name)
301 } catch {
302 return []
303 }
304}
305
306const opened = async ($: EngineInterface): Promise<void> => {
307 const key = folder((await $.session.repo())?.root ?? (await $.session.root()))
308 const stored = await $.store.get(`book:${key}`)
309 const saved: Book = { ...REST, ...(typeof stored === 'object' && stored !== null ? (stored as Partial<Book>) : {}), key }
310 const names = await named($)
311 const n = saved.sessions + 1
312 const early = await read($, book)
313 const made = planned(merged(saved, early, n), n, names.includes(SEARCH))
314
315 await update($, plan, () => made)
316 await update($, book, seen => merged(saved, seen ?? REST, n))
317 await update($, run, () => ({ ...IDLE, isDirty: early.key === '' && Object.keys(early.tools).length > 0 }))
318 if (Object.keys(made.moves).length > 0) $.ui.invalidate('tool.describe')
319}
320
321const written = async ($: EngineInterface): Promise<void> => {
322 const held = await read($, book)
323
324 await $.store.set(`book:${held.key}`, trimmed(held, (await read($, plan)).n))
325 await update($, run, ran => ({ ...(ran ?? IDLE), isDirty: false }))
326}
327
328const kept = async ($: EngineInterface): Promise<void> => {
329 const { isCounted, isDirty } = await read($, run)
330 if (isCounted && isDirty) await written($)
331}
332
333const closed = async ($: EngineInterface): Promise<void> => {
334 const { moves } = await read($, plan)
335
336 await kept($)
337 await update($, book, () => REST)
338 await update($, plan, () => UNPLANNED)
339 await update($, run, () => IDLE)
340 if (Object.keys(moves).length > 0) $.ui.invalidate('tool.describe')
341}
342
343const woken = async ($: EngineInterface): Promise<boolean> => {
344 try {
345 if ((await $.state.get(restored)).version > 0) return false
346 const isStored = (await $.store.get('isOn')) === true
347
348 return await update($, isOn, () => isStored)
349 } catch {
350 return false
351 }
352}
353
354const placed = async ($: EngineInterface, e: ToolDescribeInput, answer: ToolDescribeResult): Promise<ToolDescribeResult> => {
355 const { isReady, n, mode, moves } = await read($, plan)
356 const seen = isReady ? n : 0
357 const held = await read($, book)
358 if (noted(held, e, seen) !== held) {
359 await update($, book, held => noted(held ?? REST, e, seen))
360 if (isReady) await update($, run, ran => ({ ...(ran ?? IDLE), isDirty: true }))
361 }
362
363 const isKept = e.isDeferred === true && mode !== 'blind' && moves[e.tool] === undefined && held.keep.includes(e.tool)
364 if (isKept) {
365 await update($, plan, made => ({ ...(made ?? UNPLANNED), moves: { ...(made ?? UNPLANNED).moves, [e.tool]: 'forward' } }))
366 await update($, run, ran => ({ ...(ran ?? IDLE), isMeasured: false, onDemand: -1 }))
367 }
368
369 const move = isKept ? 'forward' : moves[e.tool]
370
371 return move === undefined ? answer : { ...answer, isDeferred: move === 'away' }
372}
373
374const counted = async ($: EngineInterface, tool: string): Promise<void> => {
375 const { isReady, n, moves } = await read($, plan)
376 if (!isReady) return
377
378 const held = await read($, book)
379 const isFetched = moves[tool] === 'away' && !(await read($, run)).fetched.includes(tool)
380 if (tallied(held, tool, n) === held && !isFetched) return
381
382 await update($, book, seen => tallied(seen ?? REST, tool, n))
383 await update($, run, ran => {
384 const { fetched, ...rest } = ran ?? IDLE
385
386 return { ...rest, isCounted: true, isDirty: true, fetched: isFetched && !fetched.includes(tool) ? [...fetched, tool] : fetched }
387 })
388}
389
390const measured = async ($: EngineInterface): Promise<void> => {
391 const { isReady, moves } = await read($, plan)
392 if (!isReady || (await read($, run)).isMeasured) return
393
394 const rows = ((await $.session.usage({ breakdown: 'summary' })).context.breakdown?.categories ?? []).filter(row => row.kind === 'deferred')
395 const onDemand = rows.length === 0 ? -1 : rows.reduce((sum, row) => sum + row.tokens, 0)
396 const isBase = onDemand >= 0 && Object.keys(moves).length === 0 && (await read($, book)).base !== onDemand
397 if (isBase) await update($, book, held => ({ ...(held ?? REST), base: onDemand }))
398 await update($, run, ran => ({ ...(ran ?? IDLE), isMeasured: true, onDemand, isDirty: (ran ?? IDLE).isDirty || isBase }))
399}
400
401const shelved = async ($: EngineInterface, list: List, typed: string): Promise<string> => {
402 const before = await read($, plan)
403 const held = await read($, book)
404 const floor = FLOOR.find(name => name.toLowerCase() === typed.toLowerCase())
405 if (list === 'stow' && floor !== undefined) return `${floor} is never put away.`
406
407 const known = [...(await named($)), ...Object.keys(held.tools), ...held.keep, ...held.stow]
408 const tool = known.find(name => name === typed) ?? known.find(name => name.toLowerCase() === typed.toLowerCase())
409 if (tool === undefined) return `No tool named ${typed} in this session.`
410
411 const { [tool]: was, ...others } = before.moves
412 const move = planned(await update($, book, seen => listed(seen ?? REST, list, tool)), before.n, before.mode !== 'blind').moves[tool]
413 const made: Plan = { ...before, moves: move === undefined ? others : { ...others, [tool]: move } }
414 await written($)
415 await update($, plan, () => made)
416 const isUnseen = list === 'keep' && !held.keep.includes(tool) && before.mode !== 'blind' && held.tools[tool]?.seen !== before.n
417 if (move !== was) await update($, run, ran => ({ ...(ran ?? IDLE), isMeasured: false, onDemand: -1 }))
418 if (move !== was || isUnseen) $.ui.invalidate('tool.describe')
419
420 if (held[list].includes(tool)) return `${tool} is back to evidence.`
421 if (list === 'keep') return `${tool} stays listed.`
422
423 return made.mode === 'blind' ? `${tool} is on the stow list. ${BLIND}` : `${tool} is in the attic from the next request. One prompt-cache miss.`
424}
425
426const cleared = async ($: EngineInterface): Promise<string> => {
427 const { key, tools } = await read($, book)
428 const { mode, moves } = await read($, plan)
429
430 await $.store.delete(`book:${key}`)
431 await update($, book, () => ({ ...REST, key }))
432 await update($, plan, () => planned(REST, 1, mode !== 'blind'))
433 await update($, run, () => IDLE)
434 if (Object.keys(moves).length > 0) $.ui.invalidate('tool.describe')
435
436 return `Attic cleared: ${plural(Object.keys(tools).length, 'tool')} forgotten for ${titled(key)}.`
437}
438
439export const register: Register = on => {
440 on('session.start', async ($, e, next) => {
441 await $.command.register({
442 name: 'attic-widget',
443 description: 'Toggle the Attic card, list where each tool waits, keep or stow one, or clear the count',
444 argumentHint: '[on|off|show|keep <tool>|stow <tool>|clear]',
445 })
446 if ((await $.store.get('isOn')) === true) await update($, isOn, () => true)
447 else await update($, isOn, shown => shown ?? false)
448 if (await read($, isOn)) await opened($)
449
450 return next(e)
451 })
452
453 on('command.run', { command: 'attic-widget' }, async ($, e) => {
454 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
455 const arg = verb.toLowerCase()
456 const [typed = ''] = rest
457 if (!VERBS.includes(arg) || rest.length !== (arg === 'keep' || arg === 'stow' ? 1 : 0)) return { text: USAGE }
458
459 if (arg === 'show' || arg === 'keep' || arg === 'stow' || arg === 'clear') {
460 if (!(await read($, isOn))) return { text: 'Attic is off.' }
461 if (arg === 'show') return { text: reported(await read($, book), await read($, plan), await read($, run)) }
462
463 return { text: arg === 'clear' ? await cleared($) : await shelved($, arg, typed) }
464 }
465
466 const wasOn = await read($, isOn)
467 const isShown = await update($, isOn, shown => (arg === '' ? !(shown ?? false) : arg === 'on'))
468 await $.store.set('isOn', isShown)
469 if (isShown && !wasOn) await opened($)
470 if (!isShown && wasOn) await closed($)
471
472 return { text: isShown ? 'Attic on; /widgets places it.' : 'Attic off.' }
473 })
474
475 on('tool.describe', async ($, e, next) => {
476 if (!(await read($, isOn)) && !(await woken($))) return next(e)
477
478 const answer = await next(e)
479 try {
480 return await placed($, e, answer)
481 } catch {
482 return answer
483 }
484 })
485
486 on('tool.call', async ($, e, next) => {
487 if (!(await read($, isOn))) return next(e)
488
489 try {
490 await counted($, e.tool)
491 } catch {
492 // A call that cannot be counted still runs: the tally is evidence, never a gate.
493 }
494
495 return next(e)
496 })
497
498 on('turn.complete', async ($, e, next) => {
499 if (e.agentId !== undefined || !(await read($, isOn))) return next(e)
500
501 try {
502 await measured($).catch(() => undefined)
503 await kept($)
504 } catch {
505 // A book that cannot be saved is kept in this session and written at the next turn.
506 }
507
508 return next(e)
509 })
510
511 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
512 show($, $.ui.resolve(e), await next(e), 'side', e.props.bodyColumns),
513 )
514
515 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
516 e.props.hasSurvey ? next(e) : show($, $.ui.resolve(e), await next(e), 'above', e.props.bodyColumns),
517 )
518
519 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
520 show($, $.ui.resolve(e), await next(e), 'below', e.viewport?.columns ?? 80),
521 )
522}
523hooks/lib.ts 45 lines1const MIN_CARD = 20
2const BARS = '▁▂▃▄▅▆▇█'
3
4export const fit = (wanted: number, columns: number): number => Math.min(wanted, Math.max(MIN_CARD, columns))
5
6export const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
7
8export const span = (ms: number): string => {
9 const seconds = Math.max(0, Math.round(ms / 1000))
10 if (seconds < 60) return `${seconds}s`
11 const minutes = Math.floor(seconds / 60)
12 if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
13 const hours = Math.floor(minutes / 60)
14 if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
15
16 return `${Math.floor(hours / 24)}d ${hours % 24}h`
17}
18
19export const spark = (values: readonly number[], width: number): string => {
20 const shown = width > 0 ? values.slice(-width) : []
21 const top = Math.max(...shown, 0)
22
23 return shown
24 .map(value => (top <= 0 ? BARS[0] : BARS[Math.min(BARS.length - 1, Math.round((Math.max(0, value) / top) * (BARS.length - 1)))]))
25 .join('')
26}
27
28export const hash = (text: string): number => {
29 let held = 2166136261
30 for (const letter of text) held = Math.imul(held ^ (letter.codePointAt(0) ?? 0), 16777619)
31
32 return held >>> 0
33}
34
35export const shade = (color: number, factor: number): number =>
36 (Math.round((color >> 16) * factor) << 16) |
37 (Math.round(((color >> 8) & 255) * factor) << 8) |
38 Math.round((color & 255) * factor)
39
40export const folder = (path: string): string => {
41 const flat = path.replaceAll('\\', '/').replace(/\/+$/, '')
42
43 return /^[a-z]:/i.test(flat) ? flat.toLowerCase() : flat
44}
45types/index.d.ts 29 lines1declare module 'claude-code' {
2 interface PluginState {
3 'attic-widget': {
4 isOn: boolean
5 book: {
6 key: string
7 sessions: number
8 base: number
9 keep: string[]
10 stow: string[]
11 tools: Record<string, { isEngine: boolean; isDeferred: boolean; since: number; seen: number; used: number[] }>
12 }
13 plan: {
14 isReady: boolean
15 n: number
16 mode: 'dry' | 'airing' | 'blind' | 'live'
17 moves: Record<string, 'away' | 'forward'>
18 }
19 run: {
20 isCounted: boolean
21 isDirty: boolean
22 isMeasured: boolean
23 onDemand: number
24 fetched: string[]
25 }
26 }
27 }
28}
29