SLOPSHOPPER

amendments-widget

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…

newpanebandspinnercommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · amendments-widget
› 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 › /amendments-widget ⎿ amendments-widget: Amendments on; /widgets places it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Widgets

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.

Install

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.

Widgets

Layout

Required by every widget.

PluginCommandWhat 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

Session

What Claude is doing and what it is costing.

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

Project and git

The state of the working tree.

PluginCommandWhat it shows
file-tree-widget/file-tree-widgetThe project directory; folders expand on click
git-widget/git-widgetBranch, 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-widgetThe commits made since the session started
todos-widget/todos-widgetTODO, 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

Time and focus

Clocks, timers and reminders that do not depend on the session.

PluginCommandWhat 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

Scenes

Pixel art that reacts to turns, tool calls, checks and context usage.

PluginCommandWhat 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

Visualizers

Abstract pictures driven by tool activity.

PluginCommandWhat 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

Games

Snake, 2048 and Tetris play themselves until you click the board and take the keys.

PluginCommandWhat 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

Just for fun

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

Notes

  • /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.
  • The pixel art is drawn with terminal block characters and shows in the terminal only.
  • 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.
  • Taking over a game, and playing Minesweeper at all, needs a mouse click on the board, which the fullscreen layout reports.

Develop

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.

Demo page

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.

The Widget Factory

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.

License

MIT

Source 3 files
hooks/register.tsx 533 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginState, PromptComposeInput, PromptComposeResult, Register, RenderElement, RenderSurface } from 'claude-code'
3import type { WidgetsPlace } from 'widgets'
4
5import { fit, plural, span } from './lib'
6
7type Tags = Pick<Elements[RenderSurface], 'Box' | 'Text'>
8type Held = PluginState['amendments-widget']['held']
9type Report = PluginState['amendments-widget']['report']
10type Row = Report['rows'][number]
11type Texts = Readonly<Record<string, string>>
12type Shot = { version: string; at: number; texts: Record<string, string> }
13type Book = { now: Shot; before: Shot | null; loose: string[] }
14type Shelf = { last: string; books: Record<string, Book> }
15type Edit = { mark: ' ' | '-' | '+'; text: string }
16type Line = { key: string; text: string; isDim: boolean }
17
18const PANE = 'widgets'
19const CARD_COLUMNS = 40
20const CARD_FRAME = 4
21const WIDE_COLUMNS = 30
22const MAX_ROWS = 6
23const MAX_BOOKS = 4
24const MAX_CELLS = 250_000
25const MAX_LINES = 200
26const CONTEXT = 2
27const FILE = 'amendments.json'
28const USAGE = 'Usage: /amendments-widget [on|off|show [n]|clear]'
29const VERBS = ['', 'on', 'off', 'show', 'clear']
30const WHOLE = /^0*([1-9]\d{0,5})$/
31const SECONDS = /^\d+s$/
32const OPENERS = ['intro', 'lean_body', 'bare']
33const EMPTY = 'No baseline yet. After your next turn what Claude Code tells Claude is saved, and each update is compared with it.'
34const AGAIN = '/amendments-widget clear starts again.'
35const FAULTS = { read: 'The snapshot file could not be read.', write: 'The snapshot file could not be written.' } as const
36const NOTES = { none: undefined, new: 'new', baseline: 'baseline', same: 'unchanged', changed: undefined, fault: 'error' } as const
37const COMPARED = 'Compared: shared sections and built-in tool descriptions as the engine wrote them, against the version last run here.'
38const CLEARED = 'Amendments cleared. A new baseline is taken at your next turn.'
39const REST_HELD: Held = { key: '', texts: {} }
40const REST_REPORT: Report = { kind: 'none', version: '', since: '', age: '', sections: 0, tools: 0, rows: [], fault: '' }
41const site = { plugin: 'widgets', key: 'site' } as const
42const widths = { plugin: 'widgets', key: 'widths' } as const
43const isOn = atom({ plugin: 'amendments-widget', key: 'isOn' } as const, false)
44const held = atom({ plugin: 'amendments-widget', key: 'held' } as const, REST_HELD)
45const report = atom({ plugin: 'amendments-widget', key: 'report' } as const, REST_REPORT)
46
47const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
48
49const isShot = (value: unknown): value is Shot =>
50  isRecord(value) &&
51  typeof value.version === 'string' &&
52  typeof value.at === 'number' &&
53  isRecord(value.texts) &&
54  Object.values(value.texts).every(text => typeof text === 'string')
55
56const isBook = (value: unknown): value is Book =>
57  isRecord(value) &&
58  isShot(value.now) &&
59  (value.before === null || isShot(value.before)) &&
60  Array.isArray(value.loose) &&
61  value.loose.every(id => typeof id === 'string')
62
63const isShelf = (value: unknown): value is Shelf =>
64  isRecord(value) && typeof value.last === 'string' && isRecord(value.books) && Object.values(value.books).every(isBook)
65
66const cut = (text: string, room: number): string => {
67  const letters = [...text]
68
69  return letters.length <= room ? text : `${letters.slice(0, Math.max(0, room - 1)).join('')}…`
70}
71
72const isSection = (id: string): boolean => id.startsWith('s/')
73
74const labelled = (id: string): string => (isSection(id) ? id.slice(2) : `${id.slice(2)} tool`)
75
76const counted = (texts: Texts): string =>
77  `${plural(Object.keys(texts).filter(isSection).length, 'section')}, ${plural(Object.keys(texts).filter(id => !isSection(id)).length, 'tool')}`
78
79const lines = (text: string | undefined): string[] => (text === undefined || text === '' ? [] : text.split('\n'))
80
81const marked = (texts: readonly string[], mark: Edit['mark']): Edit[] => texts.map(text => ({ mark, text }))
82
83const middle = (old: readonly string[], fresh: readonly string[]): Edit[] => {
84  if (old.length * fresh.length > MAX_CELLS) return [...marked(old, '-'), ...marked(fresh, '+')]
85
86  const wide = fresh.length + 1
87  const table = new Uint16Array((old.length + 1) * wide)
88  for (let row = old.length - 1; row >= 0; row -= 1) {
89    for (let column = fresh.length - 1; column >= 0; column -= 1) {
90      table[row * wide + column] =
91        old[row] === fresh[column]
92          ? (table[(row + 1) * wide + column + 1] ?? 0) + 1
93          : Math.max(table[(row + 1) * wide + column] ?? 0, table[row * wide + column + 1] ?? 0)
94    }
95  }
96
97  const edits: Edit[] = []
98  let row = 0
99  let column = 0
100  while (row < old.length && column < fresh.length) {
101    if (old[row] === fresh[column]) {
102      edits.push({ mark: ' ', text: old[row] ?? '' })
103      row += 1
104      column += 1
105    } else if ((table[(row + 1) * wide + column] ?? 0) >= (table[row * wide + column + 1] ?? 0)) {
106      edits.push({ mark: '-', text: old[row] ?? '' })
107      row += 1
108    } else {
109      edits.push({ mark: '+', text: fresh[column] ?? '' })
110      column += 1
111    }
112  }
113
114  return [...edits, ...marked(old.slice(row), '-'), ...marked(fresh.slice(column), '+')]
115}
116
117const diff = (before: readonly string[], after: readonly string[]): Edit[] => {
118  const short = Math.min(before.length, after.length)
119  let head = 0
120  while (head < short && before[head] === after[head]) head += 1
121  let tail = 0
122  while (tail < short - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail += 1
123
124  return [
125    ...marked(before.slice(0, head), ' '),
126    ...middle(before.slice(head, before.length - tail), after.slice(head, after.length - tail)),
127    ...marked(before.slice(before.length - tail), ' '),
128  ]
129}
130
131const tally = (edits: readonly Edit[]): string =>
132  `+${edits.filter(edit => edit.mark === '+').length} -${edits.filter(edit => edit.mark === '-').length}`
133
134const excerpt = (edits: readonly Edit[]): string[] => {
135  const isShown = edits.map((_, at) => edits.slice(Math.max(0, at - CONTEXT), at + CONTEXT + 1).some(edit => edit.mark !== ' '))
136  const printed: string[] = []
137  edits.forEach((edit, at) => {
138    if (isShown[at] !== true) return
139    if (printed.length > 0 && isShown[at - 1] !== true) printed.push('…')
140    printed.push(`${edit.mark} ${edit.text}`)
141  })
142
143  return printed
144}
145
146const fact = (id: string, before: string | undefined, now: string | undefined): string => {
147  if (before === now) return ''
148  if (before === undefined || now === undefined) return isSection(id) ? (before === undefined ? 'new' : 'dropped') : ''
149
150  return tally(diff(lines(before), lines(now)))
151}
152
153const amended = (book: Book): Row[] => {
154  const before = book.before?.texts
155  if (before === undefined) return []
156
157  return [...new Set([...Object.keys(book.now.texts), ...Object.keys(before)])]
158    .filter(id => !book.loose.includes(id))
159    .sort()
160    .map(id => ({ id, label: labelled(id), fact: fact(id, before[id], book.now.texts[id]) }))
161    .filter(row => row.fact !== '')
162}
163
164const reported = (book: Book | undefined, at: number, running: string): Report => {
165  if (book === undefined) return REST_REPORT
166  if (running !== '' && running !== book.now.version) return { ...REST_REPORT, kind: 'new', version: running, since: book.now.version }
167
168  const keys = Object.keys(book.now.texts)
169  const sections = keys.filter(isSection).length
170  const based = { ...REST_REPORT, version: book.now.version, sections, tools: keys.length - sections }
171  if (book.before === null) return { ...based, kind: 'baseline' }
172
173  const rows = amended(book)
174
175  return { ...based, kind: rows.length === 0 ? 'same' : 'changed', since: book.before.version, age: span(at - book.now.at), rows }
176}
177
178const merged = (book: Book | undefined, texts: Texts, version: string, at: number): Book | undefined => {
179  if (book === undefined) return { now: { version, at, texts: { ...texts } }, before: null, loose: [] }
180
181  const isSided = Object.keys(texts).some(isSection)
182  if (book.now.version !== version) {
183    return isSided
184      ? { now: { version, at, texts: { ...texts } }, before: book.now, loose: book.loose.filter(id => id in texts || id in book.now.texts) }
185      : undefined
186  }
187
188  const kept = { ...book.now.texts }
189  const loose = new Set(book.loose)
190  for (const [id, text] of Object.entries(texts)) {
191    if (kept[id] === undefined && !isSection(id)) kept[id] = text
192    else if (kept[id] !== text) loose.add(id)
193  }
194  if (isSided) for (const id of Object.keys(kept)) if (isSection(id) && texts[id] === undefined) loose.add(id)
195
196  return { now: { ...book.now, texts: kept }, before: book.before, loose: [...loose] }
197}
198
199const pruned = (books: Record<string, Book>): Record<string, Book> =>
200  Object.keys(books).length <= MAX_BOOKS
201    ? books
202    : Object.fromEntries(
203        Object.entries(books)
204          .sort(([, one], [, other]) => other.now.at - one.now.at)
205          .slice(0, MAX_BOOKS),
206      )
207
208const keyed = (e: PromptComposeInput): string => [e.promptModel, [...e.traits].sort().join('+'), e.outputStyle?.name ?? ''].join('|')
209
210const sectioned = (answer: PromptComposeResult): Record<string, string> | undefined =>
211  OPENERS.includes(answer.sections[0]?.id ?? '')
212    ? Object.fromEntries(
213        answer.sections
214          .filter(section => section.scope === 'shared' && !section.id.includes(':') && typeof section.text === 'string')
215          .map(section => [`s/${section.id}`, section.text]),
216      )
217    : undefined
218
219const own = <Answer,>(trace: readonly { plugin: string; returned: Answer | undefined }[] | undefined, answer: Answer): Answer => {
220  const links: readonly { plugin: string; returned: Answer | undefined }[] = Array.isArray(trace) ? trace : []
221
222  return links.find(link => link.plugin === 'engine')?.returned ?? answer
223}
224
225const wrapped = (text: string, room: number): string => {
226  const rows: string[] = []
227  let row: string[] = []
228  for (const word of text.split(' ')) {
229    let letters = [...word]
230    if (row.length > 0 && row.length + 1 + letters.length <= room) {
231      row = [...row, ' ', ...letters]
232      continue
233    }
234    if (row.length > 0) rows.push(row.join(''))
235    while (letters.length > room) {
236      rows.push(letters.slice(0, room).join(''))
237      letters = letters.slice(room)
238    }
239    row = letters
240  }
241
242  return [...rows, row.join('')].join('\n')
243}
244
245const versions = (before: string, now: string, room: number): string => {
246  const spaced = `${before} → ${now}`
247  if (spaced.length <= room) return spaced
248
249  const left = room - now.length - 1
250  const half = Math.floor((room - 1) / 2)
251
252  return left >= 2 ? `${cut(before, left)}→${now}` : `${cut(before, half)}→${cut(now, room - 1 - Math.min(half, before.length))}`
253}
254
255const line = (key: string, text: string, isDim = false): Line => ({ key, text, isDim })
256
257const written = ({ kind, version, since, age, sections, tools, rows, fault }: Report, isWide: boolean, inner: number): Line[] => {
258  const count = line('count', `${plural(sections, 'section')}, ${plural(tools, 'tool')}`)
259  if (kind === 'none') return [line('empty', EMPTY, true)]
260  if (kind === 'fault') return [line('said', FAULTS[fault === 'write' ? 'write' : 'read']), line('next', isWide ? AGAIN : 'clear starts again.')]
261  if (kind === 'new') return [line('said', `${version} is new here (was ${since}).`), line('next', 'Compared after your next turn.')]
262  if (kind === 'baseline') return [count, line('said', `Baseline taken at ${version}. The next update is compared with it.`)]
263  if (kind === 'same') return [count, line('said', `No change since ${since}.`), line('age', SECONDS.test(age) ? `${version} here since just now` : `${version} here for ${age}`, true)]
264
265  const folded = rows.length - MAX_ROWS
266
267  return [
268    ...(folded > 0 ? [line('more', cut(isWide ? `… ${folded} more in show` : `… ${folded} more`, inner), true)] : []),
269    line('versions', versions(since, version, inner), true),
270    line('hint', isWide ? 'show <n>: before and after' : 'show <n>', true),
271  ]
272}
273
274const opened = async ($: EngineInterface): Promise<Shelf | null> => {
275  const path = `${$.plugin.root}/${FILE}`
276  if (!(await $.fs.exists(path))) return { last: '', books: {} }
277
278  try {
279    const parsed: unknown = JSON.parse(await $.fs.read(path))
280
281    return isShelf(parsed) ? parsed : null
282  } catch {
283    return null
284  }
285}
286
287const faulted = async ($: EngineInterface, fault: 'read' | 'write'): Promise<string> => {
288  await update($, report, (): Report => ({ ...REST_REPORT, kind: 'fault', fault }))
289
290  return `${FAULTS[fault]} ${AGAIN}`
291}
292
293const look = async ($: EngineInterface): Promise<void> => {
294  try {
295    const { version } = await $.session.version()
296    const shelf = await opened($)
297    if (shelf === null) {
298      await faulted($, 'read')
299
300      return
301    }
302
303    const book = shelf.books[(await read($, held)).key || shelf.last]
304    const at = await $.clock.now()
305    await update($, report, () => reported(book, at, version))
306  } catch {
307    await update($, report, () => REST_REPORT)
308  }
309}
310
311const flush = async ($: EngineInterface): Promise<void> => {
312  const { key, texts } = await read($, held)
313  if (key === '' || Object.keys(texts).length === 0) return
314
315  try {
316    const { version } = await $.session.version()
317    const at = await $.clock.now()
318    const shelf = await opened($)
319    if (shelf === null) {
320      await faulted($, 'read')
321
322      return
323    }
324
325    const book = merged(shelf.books[key], texts, version, at)
326    const made: Shelf = { last: key, books: pruned(book === undefined ? shelf.books : { ...shelf.books, [key]: book }) }
327    const text = JSON.stringify(made)
328    if (text !== JSON.stringify(shelf)) {
329      try {
330        await $.fs.write(`${$.plugin.root}/${FILE}`, text)
331      } catch {
332        await faulted($, 'write')
333
334        return
335      }
336    }
337
338    await update($, held, kept => ({
339      key: kept?.key ?? key,
340      texts: Object.fromEntries(Object.entries(kept?.texts ?? {}).filter(([id, later]) => texts[id] !== later)),
341    }))
342    await update($, report, () => reported(made.books[key], at, ''))
343  } catch {
344    // A flush that cannot run keeps what is held for the next turn.
345  }
346}
347
348const wipe = async ($: EngineInterface): Promise<string> => {
349  await update($, held, () => REST_HELD)
350  try {
351    await $.fs.write(`${$.plugin.root}/${FILE}`, JSON.stringify({ last: '', books: {} } satisfies Shelf))
352  } catch {
353    return faulted($, 'write')
354  }
355  await update($, report, () => REST_REPORT)
356
357  return CLEARED
358}
359
360const detailed = (book: Book, rows: readonly Row[], wanted: number): string => {
361  const row = rows[wanted - 1]
362  if (row === undefined || book.before === null) return `No amendment ${wanted}. There are ${rows.length}.`
363
364  const printed = excerpt(diff(lines(book.before.texts[row.id]), lines(book.now.texts[row.id])))
365  const left = printed.length - MAX_LINES
366
367  return [
368    `${wanted} ${row.label}: ${row.fact}, ${book.before.version} → ${book.now.version}`,
369    ...printed.slice(0, MAX_LINES),
370    ...(left > 0 ? [`… ${plural(left, 'more line')}`] : []),
371  ].join('\n')
372}
373
374const told = async ($: EngineInterface, wanted: number): Promise<string> => {
375  const shelf = await opened($)
376  if (shelf === null) return `${FAULTS.read} ${AGAIN}`
377
378  const key = (await read($, held)).key || shelf.last
379  const book = shelf.books[key]
380  if (book === undefined) return 'No baseline yet.'
381
382  const rows = amended(book)
383  if (wanted > 0) return detailed(book, rows, wanted)
384  if (book.before === null) return `Baseline taken at ${book.now.version}: ${counted(book.now.texts)}. Nothing to compare yet.`
385
386  const name = key.replace(/\|+$/, '')
387
388  return [
389    rows.length === 0
390      ? `No change since ${book.before.version} (${name}).`
391      : `${book.before.version} → ${book.now.version} (${name}): ${plural(rows.length, 'amendment')}`,
392    ...rows.map((row, at) => `${at + 1}  ${row.label}  ${row.fact}`),
393    ...(book.loose.length === 0 ? [] : [`Left out, varies within one build: ${[...book.loose].sort().map(labelled).join(', ')}`]),
394    COMPARED,
395  ].join('\n')
396}
397
398const show = async (
399  $: EngineInterface,
400  { Box, Text }: Tags,
401  beneath: RenderElement,
402  place: WidgetsPlace,
403  columns: number,
404): Promise<RenderElement> => {
405  if (!(await read($, isOn))) return beneath
406  if ((await $.state.get(site)).value !== place) return beneath
407
408  const width = fit((await $.state.get(widths)).value?.['amendments-widget'] ?? CARD_COLUMNS, columns)
409  const inner = width - CARD_FRAME
410  const isWide = width >= WIDE_COLUMNS
411  const drawn = await read($, report)
412  const rows = drawn.kind === 'changed' ? drawn.rows : []
413  const note = drawn.kind !== 'changed' ? (isWide ? NOTES[drawn.kind] : undefined) : isWide ? plural(rows.length, 'amendment') : String(rows.length)
414
415  return $.widgets.card({
416    beneath,
417    width,
418    title: 'Amendments',
419    ...(note === undefined ? {} : { note }),
420    body: (
421      <Box flexDirection="column">
422        {rows.slice(0, MAX_ROWS).map((row, at) => (
423          <Box key={`row:${at + 1}`} justifyContent="space-between">
424            <Box key={`label:${at + 1}`}>
425              <Text wrap="truncate-end">{cut(`${at + 1} ${row.label}`, inner - row.fact.length - 1)}</Text>
426            </Box>
427            <Box key={`fact:${at + 1}`} flexShrink={0}>
428              <Text dimColor>{row.fact}</Text>
429            </Box>
430          </Box>
431        ))}
432        {written(drawn, isWide, inner).map(({ key, text, isDim }) => (
433          <Box key={key}>
434            <Text dimColor={isDim}>{wrapped(text, inner)}</Text>
435          </Box>
436        ))}
437      </Box>
438    ),
439  })
440}
441
442export const register: Register = on => {
443  on('session.start', async ($, e, next) => {
444    await $.command.register({
445      name: 'amendments-widget',
446      description: 'Toggle the Amendments card, show what changed or clear its snapshots',
447      argumentHint: '[on|off|show [n]|clear]',
448    })
449    if ((await $.store.get('isOn')) === true) {
450      await update($, isOn, () => true)
451      await look($)
452    }
453
454    return next(e)
455  })
456
457  on('command.run', { command: 'amendments-widget' }, async ($, e) => {
458    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
459    const arg = verb.toLowerCase()
460    const wanted = arg === 'show' && rest.length === 1 ? Number(WHOLE.exec(rest[0] ?? '')?.[1] ?? 0) : 0
461    if (!VERBS.includes(arg) || rest.length > (arg === 'show' ? 1 : 0) || (rest.length === 1 && wanted === 0)) return { text: USAGE }
462
463    if (arg === 'show' || arg === 'clear') {
464      if (!(await read($, isOn))) return { text: 'Amendments is off.' }
465
466      return { text: arg === 'clear' ? await wipe($) : await told($, wanted) }
467    }
468
469    const isShown = await update($, isOn, shown => (arg === '' ? !(shown ?? false) : arg === 'on'))
470    await $.store.set('isOn', isShown)
471    if (isShown) await look($)
472    else {
473      await update($, held, () => REST_HELD)
474      await update($, report, () => REST_REPORT)
475    }
476
477    return { text: isShown ? 'Amendments on; /widgets places it.' : 'Amendments off.' }
478  })
479
480  on('prompt.compose', async ($, e, next) => {
481    if (!(await read($, isOn))) return next(e)
482
483    const answer = await next(e)
484    try {
485      if (e.traits.includes('teammate') || e.traits.includes('analysis') || (await read($, held)).key !== '') return answer
486
487      const sections = sectioned(own(next.trace, answer))
488      if (sections !== undefined) {
489        const key = keyed(e)
490        await update($, held, kept => ((kept?.key ?? '') === '' ? { key, texts: { ...kept?.texts, ...sections } } : (kept ?? REST_HELD)))
491      }
492    } catch {
493      // A capture that fails must not cost Claude the prompt it was given.
494    }
495
496    return answer
497  })
498
499  on('tool.describe', async ($, e, next) => {
500    if (!(await read($, isOn))) return next(e)
501
502    const answer = await next(e)
503    try {
504      const text = own(next.trace, answer).description
505      if (e.provider.plugin === 'engine' && typeof text === 'string') {
506        await update($, held, kept => ({ key: kept?.key ?? '', texts: { ...kept?.texts, [`t/${e.tool}`]: text } }))
507      }
508    } catch {
509      // A capture that fails must not cost Claude the description it was given.
510    }
511
512    return answer
513  })
514
515  on('turn.complete', async ($, e, next) => {
516    if (await read($, isOn)) await flush($)
517
518    return next(e)
519  })
520
521  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
522    show($, $.ui.resolve(e), await next(e), 'side', e.props.bodyColumns),
523  )
524
525  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
526    e.props.hasSurvey ? next(e) : show($, $.ui.resolve(e), await next(e), 'above', e.props.bodyColumns),
527  )
528
529  on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
530    show($, $.ui.resolve(e), await next(e), 'below', e.viewport?.columns ?? 80),
531  )
532}
533
hooks/lib.ts 45 lines
1const 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}
45
types/index.d.ts 28 lines
1export type AmendmentsSwitch = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'amendments-widget': {
6      isOn: AmendmentsSwitch
7      held: {
8        key: string
9        texts: Record<string, string>
10      }
11      report: {
12        kind: 'none' | 'new' | 'baseline' | 'same' | 'changed' | 'fault'
13        version: string
14        since: string
15        age: string
16        sections: number
17        tools: number
18        rows: {
19          id: string
20          label: string
21          fact: string
22        }[]
23        fault: '' | 'read' | 'write'
24      }
25    }
26  }
27}
28