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…

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 522 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginState, ProcessRunInit, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3import type { WidgetsPlace } from 'widgets'
4
5import { fit, folder, hash, plural, span } from './lib'
6
7type Tags = Pick<Elements[RenderSurface], 'Box' | 'Text'>
8type Place = PluginState['green-widget']['place']
9type Run = PluginState['green-widget']['runs'][number]
10type Changed = Run['files'][number]
11type Told = { code: number; out: string; said: string }
12type Line = { key: string; mark: string; text: string; fact: string; isDim: boolean }
13
14const PANE = 'widgets'
15const CARD_COLUMNS = 40
16const CARD_FRAME = 4
17const WIDE_COLUMNS = 30
18const TOAST_COLUMNS = 40
19const TICK_MS = 30_000
20const GIT_MS = 5_000
21const SNAP_MS = 10_000
22const MAX_RUNS = 8
23const MAX_ROWS = 3
24const MAX_SHOWN = 2
25const MAX_HELD = 20
26const MAX_FAULT_ROWS = 2
27const SHA_LETTERS = 12
28const UNANSWERED = -1
29const STALLED = -2
30const REFS = 'refs/widgets/green/'
31const REF_FORMAT = '--format=%(refname)%00%(objectname)%00%(tree)%00%(committerdate:unix)%00%(contents:subject)'
32const USAGE = 'Usage: /green-widget [on|off|tell|clear]'
33const VERBS = ['', 'on', 'off', 'tell', 'clear']
34const CHECKS = /\b(test|tests|pytest|jest|vitest|tsc|lint|eslint|build|check|clippy)\b/
35const PLAIN = ['cat', 'ls', 'echo', 'grep', 'rg', 'find', 'git', 'head', 'tail', 'sed', 'awk', 'rm', 'cp', 'mv', 'mkdir', 'touch', 'which']
36const MOVES = ['cd', 'pushd', 'popd']
37const ASSIGNED = /^[A-Za-z_]\w*=/
38const SHELLED = /[|;`<>&]|\$\(/
39const OBJECT = /^[0-9a-f]{40,64}$/
40const NUMSTAT = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/
41const SLOW = 'git took over 10s.'
42const EMPTY = 'No green yet. The next passing test, lint or build is kept as a hidden git snapshot of the tree.'
43const OUTSIDE = 'Not in a git repository. Nothing is kept here.'
44const PAUSED = 'Paused until /green-widget clear.'
45const IDENTITY = {
46 GIT_AUTHOR_NAME: 'green-widget',
47 GIT_COMMITTER_NAME: 'green-widget',
48 GIT_AUTHOR_EMAIL: 'green-widget@localhost',
49 GIT_COMMITTER_EMAIL: 'green-widget@localhost',
50}
51const NOWHERE: Place = { top: '', key: '' }
52const CALM = { redAt: 0, fileCount: 0, added: 0, removed: 0, files: [] }
53const site = { plugin: 'widgets', key: 'site' } as const
54const widths = { plugin: 'widgets', key: 'widths' } as const
55const isOn = atom({ plugin: 'green-widget', key: 'isOn' } as const, false)
56const tick = atom({ plugin: 'green-widget', key: 'tick' } as const, 0)
57const isPaused = atom({ plugin: 'green-widget', key: 'isPaused' } as const, false)
58const fault = atom({ plugin: 'green-widget', key: 'fault' } as const, '')
59const where = atom({ plugin: 'green-widget', key: 'place' } as const, NOWHERE)
60const runs = atom({ plugin: 'green-widget', key: 'runs' } as const, [])
61
62let timer: Timer | undefined
63
64const cut = (text: string, room: number): string => {
65 const letters = [...text]
66
67 return letters.length <= room ? text : `${letters.slice(0, Math.max(0, room - 1)).join('')}…`
68}
69
70const tailed = (text: string, room: number): string => {
71 const letters = [...text]
72
73 return letters.length <= room ? text : `…${letters.slice(letters.length - Math.max(0, room - 1)).join('')}`
74}
75
76const wrapped = (text: string, room: number): string[] => {
77 const rows: string[] = []
78 let row = ''
79 for (const word of text.split(' ').map(each => cut(each, room))) {
80 if (row !== '' && [...row].length + 1 + [...word].length > room) {
81 rows.push(row)
82 row = word
83 } else row = row === '' ? word : `${row} ${word}`
84 }
85
86 return [...rows, row]
87}
88
89const clamped = (rows: readonly string[], room: number): string[] => {
90 if (rows.length <= MAX_FAULT_ROWS) return [...rows]
91
92 const last = [...(rows[MAX_FAULT_ROWS - 1] ?? '')].slice(0, room - 1).join('')
93
94 return [...rows.slice(0, MAX_FAULT_ROWS - 1), `${last}…`]
95}
96
97const delta = (added: number, removed: number): string => `+${added} -${removed}`
98
99const verb = (segment: string): string[] => {
100 const words = segment.split(' ')
101 const first = words.findIndex(word => !ASSIGNED.test(word))
102
103 return first === -1 ? [] : words.slice(first, first + 3)
104}
105
106const isCheck = (segment: string): boolean => {
107 const told = verb(segment)
108
109 return !PLAIN.includes(told[0] ?? '') && told.some(word => CHECKS.test(word))
110}
111
112const counted = (command: string): string | null => {
113 if (command.includes('\n')) return null
114
115 const segments = command
116 .trim()
117 .replace(/\s+/g, ' ')
118 .split('&&')
119 .map(segment => segment.trim().replace(/ 2>&1$/, ''))
120 if (segments.some(segment => segment === '' || SHELLED.test(segment) || MOVES.includes(verb(segment)[0] ?? ''))) return null
121
122 return segments.some(isCheck) ? segments.join(' && ') : null
123}
124
125const tallied = (out: string): Changed[] =>
126 out
127 .split('\0')
128 .flatMap(row => {
129 const found = NUMSTAT.exec(row)
130
131 return found === null ? [] : [{ path: found[3] ?? '', added: Number(found[1]) || 0, removed: Number(found[2]) || 0 }]
132 })
133 .sort((one, other) => other.added + other.removed - (one.added + one.removed))
134
135const ordered = (held: readonly Run[]): Run[] => [
136 ...held.filter(run => run.redAt > 0 && run.greenAt > 0).sort((one, other) => other.redAt - one.redAt),
137 ...held.filter(run => run.redAt > 0 && run.greenAt === 0).sort((one, other) => other.redAt - one.redAt),
138 ...held.filter(run => run.redAt === 0).sort((one, other) => other.greenAt - one.greenAt),
139]
140
141const spare = (held: readonly Run[]): Run | undefined => {
142 if (held.length <= MAX_RUNS) return undefined
143
144 const rest = held.slice(1)
145
146 return rest.filter(run => run.greenAt === 0).at(-1) ?? rest.reduce((oldest, run) => (run.greenAt < oldest.greenAt ? run : oldest))
147}
148
149const age = (ms: number, isWide: boolean): string => (isWide ? `${span(ms)} ago` : (span(ms).split(' ')[0] ?? ''))
150
151const plain = (key: string, rows: readonly string[], pad = '', isDim = false): Line => ({
152 key,
153 mark: '',
154 text: rows.map(row => `${pad}${row}`).join('\n'),
155 fact: '',
156 isDim,
157})
158
159const sentence = (key: string, text: string, room: number, pad = '', isDim = false): Line => plain(key, wrapped(text, room - pad.length), pad, isDim)
160
161const rowed = (run: Run, at: number, now: number, isWide: boolean, inner: number): Line => {
162 const green = age(now - run.greenAt, isWide)
163 const fact = run.redAt === 0 ? green : run.greenAt === 0 ? (isWide ? 'never green' : 'never') : isWide ? `green ${green}` : green
164
165 return {
166 key: String(at + 1),
167 mark: run.redAt > 0 ? '✗' : run.isSeen ? '✓' : '·',
168 text: cut(run.command, Math.max(1, inner - 3 - fact.length)),
169 fact,
170 isDim: false,
171 }
172}
173
174const since = (run: Run | undefined, isWide: boolean, inner: number): Line[] => {
175 if (run === undefined || run.redAt === 0 || run.greenAt === 0 || run.fileCount < 0) return []
176
177 const pad = isWide ? ' ' : ' '
178 if (run.fileCount === 0) {
179 return isWide
180 ? [sentence('since', 'since green: the tree is unchanged', inner), sentence('ignored', 'ignored files are not compared', inner, pad, true)]
181 : [sentence('since', 'tree unchanged', inner)]
182 }
183
184 const files = plural(run.fileCount, 'file')
185 const folded = run.fileCount - Math.min(MAX_SHOWN, run.files.length)
186
187 return [
188 sentence('since', isWide ? `since green: ${files}, ${delta(run.added, run.removed)}` : `${files} ${delta(run.added, run.removed)}`, inner),
189 ...run.files.slice(0, MAX_SHOWN).map((file, at) => {
190 const fact = delta(file.added, file.removed)
191
192 return { key: `file:${at + 1}`, mark: '', text: `${pad}${tailed(file.path, Math.max(1, inner - pad.length - 1 - fact.length))}`, fact, isDim: false }
193 }),
194 ...(folded > 0 ? [sentence('more', `… ${folded} more${isWide ? ' in tell' : ''}`, inner, pad, true)] : []),
195 ]
196}
197
198const written = (held: readonly Run[], place: Place, said: string, isStopped: boolean, now: number, isWide: boolean, inner: number): Line[] => {
199 if (place.top === '') return [sentence('empty', OUTSIDE, inner, '', true)]
200 if (held.length === 0 && said === '') return [sentence('empty', EMPTY, inner, '', true)]
201
202 const [first, ...rest] = held.slice(0, MAX_ROWS).map((run, at) => rowed(run, at, now, isWide, inner))
203
204 return [
205 ...(first === undefined ? [] : [first, ...since(held[0], isWide, inner), ...rest]),
206 ...(said === '' ? [] : [plain('fault', clamped(wrapped(`No snapshot: ${said}`, inner), inner))]),
207 ...(isStopped ? [sentence('paused', PAUSED, inner)] : []),
208 ]
209}
210
211const git = async ($: EngineInterface, args: readonly string[], init: ProcessRunInit): Promise<Told> => {
212 try {
213 const { exitCode, stdout, stderr } = await $.process.run(['git', '--no-optional-locks', ...args], { timeoutMs: GIT_MS, ...init })
214
215 return { code: exitCode, out: stdout, said: stderr.trim().split('\n')[0]?.trim() || `git ${args[0]} failed.` }
216 } catch {
217 return { code: UNANSWERED, out: '', said: 'git did not answer.' }
218 }
219}
220
221const sync = async ($: EngineInterface): Promise<void> => {
222 const isWanted = await read($, isOn)
223 if (isWanted && timer === undefined) {
224 timer = $.clock.every(TICK_MS, () => {
225 void update($, tick, count => ((count ?? 0) + 1) % 1_000_000)
226 })
227 }
228 if (!isWanted && timer !== undefined) {
229 timer.cancel()
230 timer = undefined
231 }
232}
233
234const look = async ($: EngineInterface): Promise<Place> => {
235 const rooted = await git($, ['rev-parse', '--show-toplevel'], { cwd: await $.session.cwd() })
236 const top = rooted.code === 0 ? rooted.out.trim() : ''
237 const held = await read($, where)
238 if (held.top === top) return held
239
240 const place = { top, key: top === '' ? '' : hash(folder(top)).toString(36) }
241 const listed = top === '' ? '' : (await git($, ['for-each-ref', REF_FORMAT, `${REFS}${place.key}/`], { cwd: top })).out
242 const restored = listed
243 .split('\n')
244 .map(row => row.split('\0'))
245 .filter(fields => fields.length === 5)
246 .map(([ref = '', sha = '', tree = '', date = '', command = '']) => ({
247 ...CALM,
248 key: ref.slice(ref.lastIndexOf('/') + 1),
249 command,
250 greenAt: Number(date) * 1000,
251 sha,
252 tree,
253 isSeen: false,
254 }))
255 .sort((one, other) => other.greenAt - one.greenAt)
256 .slice(0, MAX_RUNS)
257 await update($, where, () => place)
258 await update($, runs, () => restored)
259
260 return place
261}
262
263const stored = async ($: EngineInterface, place: Place, run: Run): Promise<void> => {
264 const gone = spare(await update($, runs, held => [run, ...(held ?? []).filter(other => other.key !== run.key)]))
265 if (gone === undefined) return
266
267 if (gone.greenAt > 0) await git($, ['update-ref', '-d', `${REFS}${place.key}/${gone.key}`], { cwd: place.top })
268 await update($, runs, held => (held ?? []).filter(other => other.key !== gone.key))
269}
270
271const snapped = async ($: EngineInterface, place: Place): Promise<Told> => {
272 const env = { GIT_INDEX_FILE: `${$.plugin.root}/index-${place.key}` }
273 const added = await git($, ['add', '-A'], { cwd: place.top, env, timeoutMs: SNAP_MS })
274 if (added.code === UNANSWERED) return { code: STALLED, out: '', said: SLOW }
275 if (added.code !== 0) return added
276
277 const made = await git($, ['write-tree'], { cwd: place.top, env })
278 const tree = made.out.trim()
279
280 return made.code === 0 && !OBJECT.test(tree) ? { code: 1, out: '', said: 'git wrote no tree.' } : { ...made, out: tree }
281}
282
283const committed = async ($: EngineInterface, place: Place, tree: string, command: string, ref: string, at: number): Promise<Told> => {
284 const date = `@${Math.floor(at / 1000)} +0000`
285 const made = await git($, ['commit-tree', tree, '--no-gpg-sign', '-m', command], {
286 cwd: place.top,
287 env: { ...IDENTITY, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
288 })
289 const sha = made.out.trim()
290 if (made.code !== 0) return made
291 if (!OBJECT.test(sha)) return { code: 1, out: '', said: 'git wrote no commit.' }
292
293 const moved = await git($, ['update-ref', ref, sha], { cwd: place.top })
294
295 return moved.code === 0 ? { ...made, out: sha } : moved
296}
297
298const compared = async ($: EngineInterface, place: Place, held: Run, tree: string, at: number): Promise<Run> => {
299 const red = { ...held, ...CALM, redAt: at, isSeen: true }
300 if (tree === held.tree) return red
301
302 const told = await git($, ['diff', '--numstat', '--no-renames', '-z', held.tree, tree], { cwd: place.top })
303 if (told.code !== 0) return { ...red, fileCount: -1 }
304
305 const files = tallied(told.out)
306
307 return {
308 ...red,
309 fileCount: files.length,
310 added: files.reduce((sum, file) => sum + file.added, 0),
311 removed: files.reduce((sum, file) => sum + file.removed, 0),
312 files: files.slice(0, MAX_HELD),
313 }
314}
315
316const judged = async ($: EngineInterface, command: string, isPassed: boolean): Promise<void> => {
317 const place = await look($)
318 if (place.top === '') return
319
320 const key = hash(command).toString(36)
321 const held = (await read($, runs)).find(run => run.key === key)
322 const at = await $.clock.now()
323 if (!isPassed && (held === undefined || held.greenAt === 0)) {
324 await stored($, place, { ...CALM, key, command, greenAt: 0, sha: '', tree: '', redAt: at, isSeen: true })
325
326 return
327 }
328
329 const shot = await snapped($, place)
330 const made = shot.code === 0 && isPassed ? await committed($, place, shot.out, command, `${REFS}${place.key}/${key}`, at) : shot
331 await update($, fault, () => (made.code === 0 ? '' : made.said))
332 await update($, isPaused, () => made.code === STALLED)
333
334 if (!isPassed && held !== undefined) {
335 const red = shot.code === 0 ? await compared($, place, held, shot.out, at) : { ...held, ...CALM, redAt: at, fileCount: -1, isSeen: true }
336 await stored($, place, red)
337 if (held.redAt > 0 || red.fileCount < 0) return
338
339 const name = cut(command, TOAST_COLUMNS)
340 $.ui.toast(
341 red.fileCount === 0
342 ? `${name} went red with the tree unchanged since green`
343 : `${name} went red: ${plural(red.fileCount, 'file')}, ${delta(red.added, red.removed)} since green ${span(at - held.greenAt)} ago`,
344 )
345 } else if (made.code === 0) {
346 await stored($, place, { ...CALM, key, command, greenAt: Math.floor(at / 1000) * 1000, sha: made.out, tree: shot.out, isSeen: true })
347 } else if (held !== undefined && held.greenAt > 0) {
348 await stored($, place, { ...held, ...CALM, isSeen: true })
349 } else {
350 await update($, runs, list => (list ?? []).filter(other => other.key !== key))
351 }
352}
353
354const handed = async ($: EngineInterface): Promise<string> => {
355 const [run] = ordered(await read($, runs))
356 if (run === undefined || run.redAt === 0) return 'Nothing is red.'
357 if (run.greenAt === 0) return `${run.command} has never been green here: nothing to compare.`
358 if (run.fileCount === 0) return `Nothing in the snapshot changed since ${run.command} was green. Ignored files are not compared.`
359 if (run.fileCount < 0) return `${run.command} is red, but git could not compare the tree with its green. Run it again.`
360
361 const now = await $.clock.now()
362 const { key } = await read($, where)
363 const sha = run.sha.slice(0, SHA_LETTERS)
364 const folded = run.fileCount - run.files.length
365 const text = [
366 `\`${run.command}\` passed ${span(now - run.greenAt)} ago and fails now. The tree as it was when it passed is the git commit ${sha} (${REFS}${key}/${run.key}).`,
367 [
368 `At the failing run ${span(now - run.redAt)} ago, ${plural(run.fileCount, 'file')} had changed since then, ${delta(run.added, run.removed)}:`,
369 ...run.files.map(file => `${file.path} ${delta(file.added, file.removed)}`),
370 ...(folded > 0 ? [`and ${folded} more`] : []),
371 ].join('\n'),
372 `Read a file's hunks with \`git diff ${sha} -- <path>\`. A file listed here that the diff does not show is new since green: read it whole. Ignored files are not in the snapshot.`,
373 `Find which of these changes broke \`${run.command}\` before looking anywhere else.`,
374 ].join('\n\n')
375
376 try {
377 return (await $.prompt.fill({ text })).isFilled ? `Filled the prompt with what changed since ${run.command} was green.` : text
378 } catch {
379 return text
380 }
381}
382
383const rested = async ($: EngineInterface): Promise<void> => {
384 await update($, runs, () => [])
385 await update($, fault, () => '')
386 await update($, isPaused, () => false)
387}
388
389const wiped = async ($: EngineInterface): Promise<string> => {
390 const place = await look($)
391 if (place.top === '') return 'Not in a git repository.'
392
393 const found = await git($, ['for-each-ref', '--format=%(refname)', REFS], { cwd: place.top })
394 const refs = found.out.split('\n').filter(ref => ref.startsWith(REFS))
395 const dropped =
396 found.code !== 0 || refs.length === 0
397 ? found
398 : await git($, ['update-ref', '--stdin'], { cwd: place.top, stdin: refs.map(ref => `delete ${ref}\n`).join('') })
399 if (dropped.code !== 0) return `Last green not cleared: ${dropped.said}`
400
401 await git($, ['read-tree', '--empty'], { cwd: place.top, env: { GIT_INDEX_FILE: `${$.plugin.root}/index-${place.key}` } })
402 await rested($)
403
404 return `Last green cleared: ${plural(refs.length, 'snapshot')} deleted.`
405}
406
407const show = async (
408 $: EngineInterface,
409 { Box, Text }: Tags,
410 beneath: RenderElement,
411 place: WidgetsPlace,
412 columns: number,
413): Promise<RenderElement> => {
414 if (!(await read($, isOn))) return beneath
415 if ((await $.state.get(site)).value !== place) return beneath
416
417 await read($, tick)
418 const width = fit((await $.state.get(widths)).value?.['green-widget'] ?? CARD_COLUMNS, columns)
419 const isWide = width >= WIDE_COLUMNS
420 const held = ordered(await read($, runs))
421 const said = await read($, fault)
422 const reds = held.filter(run => run.redAt > 0).length
423 const isAllSeen = isWide && held.length > 0 && held.every(run => run.isSeen)
424 const note = said !== '' ? 'error' : reds > 0 ? `${reds} red` : isAllSeen ? 'all green' : undefined
425 const lines = written(held, await read($, where), said, await read($, isPaused), await $.clock.now(), isWide, width - CARD_FRAME)
426
427 return $.widgets.card({
428 beneath,
429 width,
430 title: 'Last green',
431 ...(note === undefined ? {} : { note }),
432 body: (
433 <Box flexDirection="column">
434 {lines.map(line => (
435 <Box key={`row:${line.key}`} columnGap={1}>
436 {line.mark !== '' && (
437 <Box key={`mark:${line.key}`} flexShrink={0}>
438 <Text {...(line.mark === '·' ? { dimColor: true } : { color: line.mark === '✗' ? 'red' : 'green' })}>{line.mark}</Text>
439 </Box>
440 )}
441 <Box key={`text:${line.key}`} flexGrow={1}>
442 <Text dimColor={line.isDim}>{line.text}</Text>
443 </Box>
444 {line.fact !== '' && (
445 <Box key={`fact:${line.key}`} flexShrink={0}>
446 <Text dimColor>{line.fact}</Text>
447 </Box>
448 )}
449 </Box>
450 ))}
451 </Box>
452 ),
453 })
454}
455
456export const register: Register = on => {
457 on('session.start', async ($, e, next) => {
458 await $.command.register({
459 name: 'green-widget',
460 description: 'Toggle the Last green card, tell Claude what changed since green or clear the snapshots',
461 argumentHint: '[on|off|tell|clear]',
462 })
463 if ((await $.store.get('isOn')) === true) {
464 await update($, isOn, () => true)
465 await look($)
466 }
467 await sync($)
468
469 return next(e)
470 })
471
472 on('command.run', { command: 'green-widget' }, async ($, e) => {
473 const arg = e.args.trim().toLowerCase()
474 if (!VERBS.includes(arg)) return { text: USAGE }
475
476 if (arg === 'tell' || arg === 'clear') {
477 if (!(await read($, isOn))) return { text: 'Last green is off.' }
478
479 return { text: arg === 'tell' ? await handed($) : await wiped($) }
480 }
481
482 const isShown = await update($, isOn, shown => (arg === '' ? !(shown ?? false) : arg === 'on'))
483 await $.store.set('isOn', isShown)
484 if (isShown) await look($)
485 else {
486 await update($, where, () => NOWHERE)
487 await rested($)
488 }
489 await sync($)
490
491 return { text: isShown ? 'Last green on; /widgets places it.' : 'Last green off.' }
492 })
493
494 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
495 if (!(await read($, isOn))) return next(e)
496
497 const ran = await next(e)
498 try {
499 const command = counted(e.command)
500 const isAside = e.agentId !== undefined || e.run_in_background === true || ran.deny !== undefined
501 const isBehind = ran.isError !== true && ran.result?.backgroundTaskId !== undefined
502 if (command !== null && !isAside && !isBehind && !(await read($, isPaused))) await judged($, command, ran.isError !== true)
503 } catch {
504 // The Bash result is handed on whatever became of the snapshot.
505 }
506
507 return ran
508 })
509
510 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
511 show($, $.ui.resolve(e), await next(e), 'side', e.props.bodyColumns),
512 )
513
514 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
515 e.props.hasSurvey ? next(e) : show($, $.ui.resolve(e), await next(e), 'above', e.props.bodyColumns),
516 )
517
518 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
519 show($, $.ui.resolve(e), await next(e), 'below', e.viewport?.columns ?? 80),
520 )
521}
522hooks/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 34 lines1export type GreenSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'green-widget': {
6 isOn: GreenSwitch
7 tick: number
8 isPaused: boolean
9 fault: string
10 place: {
11 top: string
12 key: string
13 }
14 runs: {
15 key: string
16 command: string
17 greenAt: number
18 sha: string
19 tree: string
20 redAt: number
21 fileCount: number
22 added: number
23 removed: number
24 files: {
25 path: string
26 added: number
27 removed: number
28 }[]
29 isSeen: boolean
30 }[]
31 }
32 }
33}
34