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

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 812 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginState, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3import type { WidgetsPlace } from 'widgets'
4
5import type { AimKind, AimRow, AimShot } from '../types'
6import { fit, plural, span } from './lib'
7
8type Tags = Pick<Elements[RenderSurface], 'Box' | 'Text'>
9type Aim = PluginState['aim-widget']['aim']
10type Env = Readonly<Record<string, string | undefined>>
11type Over = Readonly<Record<string, string>>
12type Place = { cwd: string; isWindows: boolean }
13type Probes = Map<string, Promise<string>>
14type Target = { name: string; phrase: string; source: string }
15type Ask = { kind: AimKind; ask: 'ambient' | 'psql' | 'url'; dir: string | undefined; over: Over; base?: string }
16type Marks = Partial<Record<AimKind, string>>
17type Settled = { kind: AimKind } & (Target | { why: string })
18type Shot = Settled | Ask
19type Frame = { words: string[]; word: string; shut: string; isGroup: boolean }
20type Reading = { over: Over; dir: string | undefined; set: Marks; moot: Marks; isChanged: boolean; shots: Shot[]; outer: readonly Reading[] }
21
22const PANE = 'widgets'
23const CARD_COLUMNS = 40
24const USAGE = 'Usage: /aim-widget [on|off|prod <word>|unprod <word>|show|clear]'
25const OFF = 'Aim is off.'
26const LOOKING = 'Looking for targets…'
27const NONE = 'No outside targets found here.'
28const EMPTY = `${NONE} A kube context, cloud profile, project, workspace or database host shows once one is set on this machine.`
29const WINDOWS = /^[a-z]:[\\/]|^\\\\/i
30const WORD = /^[a-z0-9._-]{2,32}$/
31const ASSIGNED = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s
32const MENTIONED = /kubectl|helm|aws|gcloud|terraform|psql/i
33const LINK = /^postgres(?:ql)?:\/\//
34const HEREDOC = /<<-?\s*(["']?)([A-Za-z_]\w*)\1/
35const ENV_DRIVE = /(?:^|[^a-z0-9_])env:|SetEnvironmentVariable/i
36const UNSET_FLAG = /^(?:--unset=|-u)(\w+)$/
37const LINK_VARIABLE = /^(?:--dbname=)?\$\{?DATABASE_URL\}?$/
38const CONTINUED = /\\\r?\n/g
39const TICK_CONTINUED = /`\r?\n/g
40const PSQL_VALUED =
41 /^(?:-[aAbeEnqsStxXwWlz1]*[cdfvoLFPRThpU]|--(?:command|dbname|file|set|variable|output|log-file|field-separator|pset|record-separator|table-attr|host|port|username))$/
42const URL_PARTS = /^[a-z][a-z0-9+.-]*:\/\/(?:[^/?#]*@)?(\[[^\]]*\]|[^:/?#]*)(?::\d*)?(?:\/([^?#]*))?/i
43const TOOLS = new Map<string, AimKind>([
44 ['kubectl', 'kube'],
45 ['helm', 'kube'],
46 ['aws', 'aws'],
47 ['gcloud', 'gcp'],
48 ['terraform', 'tf'],
49 ['psql', 'db'],
50])
51const KINDS: readonly AimKind[] = ['kube', 'aws', 'gcp', 'tf', 'db']
52const PHRASES: Readonly<Record<AimKind, string>> = { kube: 'kube context', aws: 'aws profile', gcp: 'gcloud project', tf: 'terraform workspace', db: 'database' }
53const VARIABLES: Readonly<Record<AimKind, readonly string[]>> = {
54 kube: [],
55 aws: ['AWS_ACCESS_KEY_ID', 'AWS_PROFILE', 'AWS_DEFAULT_PROFILE'],
56 gcp: ['CLOUDSDK_CORE_PROJECT'],
57 tf: ['TF_WORKSPACE', 'TF_DATA_DIR'],
58 db: ['DATABASE_URL', 'PGHOST', 'PGDATABASE'],
59}
60const WRAPPERS = ['sudo', 'env', 'time', 'command', 'exec', 'nohup', 'xargs', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', '!']
61const SOURCING = ['source', '.', 'eval']
62const EXPORTING = ['export', 'declare', 'typeset', 'readonly', 'local']
63const MOVING = ['cd', 'chdir', 'pushd', 'popd', 'set-location', 'sl', 'push-location', 'pop-location']
64const UNSETTING = ['-u', '--unset']
65const VALUED = [...UNSETTING, '-I']
66const ELSEWHERE: Readonly<Record<string, readonly string[]>> = { kubectl: ['--cluster', '--server', '-s', '--user'], helm: ['--kube-apiserver'] }
67const OPEN = '('
68const SHUT = ')'
69const HOLE = '$()'
70const OTHER_USER = /^(?:-u|--user(?:=|$))/
71const SHELL = ['cmd', '/d', '/c']
72const KUBECTL = ['kubectl', 'config', 'current-context']
73const GCLOUD = ['gcloud', 'config', 'get-value', 'project']
74const NAMED = 'named in the command'
75const ENVIRONMENT = 'from the environment'
76const FROM_VARIABLE = 'set from a variable'
77const INDIRECT = 'not a direct call'
78const RECONFIGURED = 'another configuration given'
79const REPOINTED = 'context changed in the command'
80const GIVEN = 'connection string given'
81const UNSET = '$'
82const PROBE_MS = 3000
83const TICK_MS = 1000
84const REFRESH_MS = 30_000
85const WORDS_MAX = 8
86const LINES_MAX = 3
87const COMMAND_MAX = 80
88const WIDE_COLUMNS = 30
89const BLANK: Aim = { at: 0, isBusy: false, rows: [], last: null }
90const site = { plugin: 'widgets', key: 'site' } as const
91const widths = { plugin: 'widgets', key: 'widths' } as const
92const isOn = atom({ plugin: 'aim-widget', key: 'isOn' } as const, false)
93const words = atom({ plugin: 'aim-widget', key: 'words' } as const, ['prod'])
94const aim = atom({ plugin: 'aim-widget', key: 'aim' } as const, BLANK)
95
96let timer: Timer | undefined
97
98const cut = (text: string, room: number): string => {
99 if (text.length <= room) return text
100
101 return room < 1 ? '' : `${text.slice(0, room - 1).replace(/…$/, '')}…`
102}
103
104const wrapped = (text: string, room: number): string[] =>
105 text.split(' ').reduce<string[]>((lines, word) => {
106 const last = lines.at(-1)
107
108 return last !== undefined && last.length + 1 + word.length <= room ? [...lines.slice(0, -1), `${last} ${word}`] : [...lines, cut(word, room)]
109 }, [])
110
111const ago = (ms: number): string => span(ms).split(' ')[0] ?? ''
112
113const bare = (word: string): string => (word.length >= 2 && (word.startsWith('"') || word.startsWith("'")) && word.endsWith(word.charAt(0)) ? word.slice(1, -1) : word)
114
115const valued = (value: string): string => (value.replace(/\\/g, '').trim() === '' || /["'$`]/.test(value) ? UNSET : value)
116
117const toolOf = (word: string): string => (word.split(/[\\/]/).at(-1) ?? '').replace(/\.exe$/i, '').toLowerCase()
118
119// A heredoc's body is text handed to the command, not commands of its own.
120const spoken = (command: string): string => {
121 let end: string | undefined
122
123 return command
124 .split('\n')
125 .filter(line => {
126 if (end !== undefined) {
127 if (line.trim() === end) end = undefined
128
129 return false
130 }
131 end = HEREDOC.exec(line.replace(/<<</g, ' '))?.[2]
132
133 return true
134 })
135 .join('\n')
136}
137
138// Quotes and an escape (a backslash in Bash, a backtick in PowerShell) keep a separator as text, so a commit message or a query is one word.
139// Whatever opens with a bracket or a backtick is read first, between OPEN and SHUT, which no unquoted word can equal. Unless it began a
140// command of its own, the command it was written in then carries on, with HOLE where its output would stand.
141// A brace separates only as a word of its own, or in PowerShell around a script block; inside a word it is text.
142// A lone & ends a command as ; does, and in PowerShell starts one; the & of 2>&1 and &> belongs to its redirection.
143const segmentsOf = (command: string, isBash: boolean): string[][] => {
144 const segments: string[][] = []
145 const outer: Frame[] = []
146 const escape = isBash ? '\\' : '`'
147 let frame: Frame = { words: [], word: '', shut: '', isGroup: false }
148 let quote = ''
149 let blocks = 0
150 const close = (): void => {
151 if (frame.word !== '' && frame.word !== '\\') frame.words.push(frame.word)
152 frame.word = ''
153 }
154 const end = (): void => {
155 close()
156 if (frame.words.length > 0) segments.push(frame.words)
157 frame.words = []
158 }
159 const open = (shut: string, isGroup: boolean): void => {
160 segments.push([OPEN])
161 outer.push(frame)
162 frame = { words: [], word: '', shut, isGroup }
163 }
164 const shut = (): void => {
165 end()
166 segments.push([SHUT])
167 const before = outer.pop()
168 if (before === undefined) return
169 if (!frame.isGroup) before.word += HOLE
170 frame = before
171 }
172 for (let at = 0; at < command.length; at += 1) {
173 const letter = command.charAt(at)
174 const after = command.charAt(at + 1)
175 const pair = command.slice(at, at + 2)
176 const isEdge = after === '' || /[\s;|&)]/.test(after)
177 if (letter === escape && quote !== "'" && after !== '') {
178 frame.word += pair
179 at += 1
180 } else if (quote !== '') {
181 frame.word += letter
182 if (letter === quote) quote = ''
183 } else if (letter === '"' || letter === "'") {
184 quote = letter
185 frame.word += letter
186 } else if (letter === '#' && frame.word === '') {
187 const rest = command.indexOf('\n', at)
188 at = rest < 0 ? command.length : rest - 1
189 } else if (pair === '$(') {
190 open(SHUT, false)
191 at += 1
192 } else if (letter === OPEN) open(SHUT, frame.word === '' && frame.words.length === 0)
193 else if (letter === '`' && isBash) {
194 if (frame.shut === letter) shut()
195 else open(letter, false)
196 } else if (letter === SHUT) {
197 if (frame.shut === letter) shut()
198 else end()
199 } else if (letter === '{' && frame.word === '' && (after === '' || /\s/.test(after))) end()
200 else if (letter === '{' && frame.word === '' && !isBash && after !== '{' && after !== '}') {
201 blocks += 1
202 end()
203 } else if (letter === '}' && frame.word === '' && isEdge) end()
204 else if (letter === '}' && !isBash && blocks > 0 && isEdge) {
205 blocks -= 1
206 end()
207 } else if (pair === '&&' || pair === '||') {
208 end()
209 at += 1
210 } else if (';|\n'.includes(letter) || (letter === '&' && !/[<>]$/.test(frame.word) && after !== '>')) end()
211 else if (/\s/.test(letter)) close()
212 else frame.word += letter
213 }
214 while (outer.length > 0) shut()
215 end()
216
217 return segments
218}
219
220
221const headOf = (raws: readonly string[]): number => {
222 let at = 0
223 let isWrapped = false
224 for (; at < raws.length; at += 1) {
225 const raw = raws[at] ?? ''
226 if (WRAPPERS.includes(raw)) isWrapped = true
227 else if (!ASSIGNED.test(raw) && !(isWrapped && (raw.startsWith('-') || VALUED.includes(raws[at - 1] ?? '')))) break
228 }
229
230 return at
231}
232
233const assigned = (over: Over, raw: string): Over => {
234 const [, name, value = ''] = ASSIGNED.exec(raw) ?? []
235
236 return name === undefined ? over : { ...over, [name]: valued(bare(value)) }
237}
238
239const dropped = (over: Over, name: string): Over => ({ ...over, [name]: '' })
240
241const prefixed = (over: Over, raws: readonly string[]): Over =>
242 raws.reduce((held, raw, at) => {
243 const name = UNSETTING.includes(raws[at - 1] ?? '') ? raw : UNSET_FLAG.exec(raw)?.[1]
244
245 return name === undefined ? assigned(held, raw) : dropped(held, name)
246 }, over)
247
248const attached = (texts: readonly string[], flag: string): string | undefined => {
249 const text = texts.find(other => other.startsWith(flag) && other.length > flag.length && !other.startsWith('--'))
250
251 return text === undefined ? undefined : valued(bare(text.slice(flag.length)))
252}
253
254// A flag given twice is read as these tools read it: the last one wins.
255const flagged = (texts: readonly string[], names: readonly string[]): string | undefined =>
256 texts.reduce<string | undefined>((found, text, at) => {
257 const [flag = '', ...rest] = text.split('=')
258
259 return names.includes(flag) ? valued(rest.length > 0 ? bare(rest.join('=')) : (texts[at + 1] ?? '')) : found
260 }, undefined)
261
262// sudo -u runs the tool as someone else, whose kubeconfig, profile and environment are not this user's.
263const isBorrowed = (raws: readonly string[]): boolean => {
264 let wrapper = ''
265
266 return raws.some(raw => {
267 if (WRAPPERS.includes(raw)) wrapper = raw
268
269 return wrapper === 'sudo' && OTHER_USER.test(raw)
270 })
271}
272
273const partsOf = (url: string): [host: string, base: string] | undefined => {
274 const [, host, base = ''] = URL_PARTS.exec(url) ?? []
275
276 return host === undefined ? undefined : [host === '' ? 'localhost' : host, base]
277}
278
279const reachOf = (url: string): string | undefined => {
280 const [host, base] = partsOf(url) ?? []
281
282 return host === undefined ? undefined : base ? `${host}/${base}` : host
283}
284
285const moved = (dir: string | undefined, path: string | undefined, isWindows: boolean): string | undefined => {
286 if (dir === undefined || path === undefined || valued(path) === UNSET || path.startsWith('~')) return undefined
287 if (WINDOWS.test(path)) return isWindows ? path : undefined
288 // A Git Bash path such as /c/infra is not one $.fs can be trusted to resolve.
289 if (path.startsWith('/')) return isWindows ? undefined : path
290
291 return `${dir}/${path}`
292}
293
294const setterOf = (head: string, plain: readonly string[]): string | undefined => {
295 const [first, second, third, fourth] = plain
296 if (head === 'kubectl' && first === 'config' && (second === 'use-context' || second === 'use')) return valued(third ?? '')
297 if (head === 'kubectl' && first === 'config' && second === 'set' && third === 'current-context') return valued(fourth ?? '')
298 if (head === 'terraform' && first === 'workspace' && (second === 'select' || second === 'new')) return valued(third ?? '')
299 if (head === 'gcloud' && first === 'config' && second === 'set' && (third === 'project' || third === 'core/project')) return valued(fourth ?? '')
300
301 return undefined
302}
303
304// These rewrite the kubeconfig or gcloud's active configuration after the probe has answered for the old one.
305const mootOf = (head: string, plain: readonly string[]): Marks => {
306 const isRepointing =
307 head === 'kubectx' ||
308 (head === 'aws' && plain.includes('update-kubeconfig')) ||
309 ((head === 'gcloud' || head === 'az') && plain.includes('get-credentials')) ||
310 (head === 'kubectl' && plain[0] === 'config' && plain[1] === 'unset' && plain[2] === 'current-context')
311 if (isRepointing) return { kube: REPOINTED }
312
313 return head === 'gcloud' && plain.includes('configurations') && plain.includes('activate') ? { gcp: RECONFIGURED } : {}
314}
315
316const named = (kind: AimKind, name: string | undefined, phrase = PHRASES[kind]): Settled =>
317 name === undefined || name === UNSET ? { kind, why: FROM_VARIABLE } : { kind, name, phrase, source: NAMED }
318
319const direct = (head: string, kind: AimKind, texts: readonly string[], held: Reading, set: string | undefined, isWindows: boolean): Shot => {
320 const { over, dir } = held
321 const ask: Ask = { kind, ask: 'ambient', dir, over }
322 if (kind === 'kube') {
323 const until = texts.indexOf('--')
324 const own = until < 0 ? texts : texts.slice(0, until)
325 if (flagged(own, ['--kubeconfig']) !== undefined || 'KUBECONFIG' in over) return { kind, why: 'another kubeconfig given' }
326 if (flagged(own, ELSEWHERE[head] ?? []) !== undefined) return { kind, why: 'another cluster given' }
327 const context = flagged(own, [head === 'helm' ? '--kube-context' : '--context']) ?? set
328
329 return context === undefined ? ask : named(kind, context)
330 }
331 if (kind === 'aws') {
332 const endpoint = flagged(texts, ['--endpoint-url'])
333 if (endpoint !== undefined) return named(kind, partsOf(endpoint)?.[0], 'aws endpoint')
334 const profile = flagged(texts, ['--profile'])
335
336 return profile === undefined ? ask : named(kind, profile)
337 }
338 if (kind === 'gcp') {
339 const project = flagged(texts, ['--project']) ?? set
340 if (project !== undefined) return named(kind, project)
341
342 return flagged(texts, ['--configuration', '--account']) === undefined && !over.CLOUDSDK_ACTIVE_CONFIG_NAME ? ask : { kind, why: RECONFIGURED }
343 }
344 if (kind === 'tf') {
345 const chdir = texts.find(text => text.startsWith('-chdir='))
346 if (set !== undefined && !over.TF_WORKSPACE) return named(kind, set)
347
348 return chdir === undefined ? ask : { ...ask, dir: moved(dir, bare(chdir.slice('-chdir='.length)), isWindows) }
349 }
350
351 const lone = texts.find((text, at) => !text.startsWith('-') && !text.includes('=') && !PSQL_VALUED.test(texts[at - 1] ?? ''))
352 const base = flagged(texts, ['-d', '--dbname']) ?? attached(texts, '-d') ?? (lone === undefined ? undefined : valued(lone))
353 const link = [base, ...texts].find(text => text !== undefined && LINK.test(text))
354 if (link !== undefined) return named(kind, valued(link) === UNSET ? undefined : reachOf(link))
355 if (texts.some(text => LINK_VARIABLE.test(text))) return { ...ask, ask: 'url' }
356 const host = flagged(texts, ['-h', '--host']) ?? attached(texts, '-h')
357 if (host !== undefined) return named(kind, host === UNSET || base === UNSET ? undefined : base === undefined ? host : `${host}/${base}`)
358 if (texts.some(text => /(?:^|\s)(?:host|service)=/.test(text))) return { kind, why: GIVEN }
359
360 return { ...ask, ask: 'psql', ...(base === undefined ? {} : { base }) }
361}
362
363const headsIn = (text: string, isBash: boolean): AimKind[] =>
364 segmentsOf(text, isBash).flatMap(raws => {
365 const kind = TOOLS.get(toolOf(bare(raws[headOf(raws)] ?? '')))
366
367 return [...(kind === undefined ? [] : [kind]), ...raws.filter(raw => bare(raw) !== raw).flatMap(raw => headsIn(bare(raw), isBash))]
368 })
369
370const stepOf = (held: Reading, raws: readonly string[], isWindows: boolean, isBash: boolean): Reading => {
371 const at = headOf(raws)
372 const texts = raws.map(bare)
373 const head = toolOf(texts[at] ?? '')
374 const rest = texts.slice(at + 1)
375 const plain = rest.filter(text => !text.startsWith('-'))
376 const local = prefixed(held.over, raws.slice(0, at))
377 if (at === raws.length) return { ...held, over: local }
378 if (EXPORTING.includes(head)) return { ...held, over: raws.slice(at + 1).reduce(assigned, held.over) }
379 if (head === 'unset') return { ...held, over: plain.reduce(dropped, held.over) }
380 if (MOVING.includes(head)) {
381 const { tf: _tf, ...set } = held.set
382
383 return { ...held, set, dir: moved(held.dir, plain[0], isWindows) }
384 }
385
386 const kind = TOOLS.get(head)
387 const set = kind === undefined ? undefined : setterOf(head, plain)
388 // A workspace chosen or read under -chdir belongs to that folder, so it neither carries on nor is carried in.
389 const isAside = kind === 'tf' && rest.some(text => text.startsWith('-chdir='))
390 const carried = kind === undefined || isAside ? undefined : held.set[kind]
391 const quoted = raws.filter((raw, index) => bare(raw) !== raw && (kind === undefined || index !== at)).flatMap(raw => headsIn(bare(raw), isBash))
392 // Inside a listed tool's own call another tool's name is an argument, a namespace or a chart, so only other commands are read for a mention.
393 const loose = kind === undefined ? raws.flatMap((raw, index) => (bare(raw) === raw ? (TOOLS.get(toolOf(texts[index] ?? '')) ?? []) : [])) : []
394 const taken = kind === undefined ? undefined : direct(head, kind, rest, { ...held, over: local }, set ?? carried, isWindows)
395 const shot: Shot | undefined = taken !== undefined && 'ask' in taken && isBorrowed(raws.slice(0, at)) ? { kind: taken.kind, why: 'another user given' } : taken
396 const fresh = mootOf(head, plain)
397 const moot = { ...held.moot, ...fresh }
398 const doubt = shot !== undefined && 'ask' in shot && shot.ask === 'ambient' ? moot[shot.kind] : undefined
399 const mine: Shot[] =
400 kind === undefined || shot === undefined ? [] : [held.isChanged ? { kind, why: 'environment changed in the command' } : doubt === undefined ? shot : { kind, why: doubt }]
401 const kept: Marks = Object.fromEntries(Object.entries(held.set).filter(([other]) => !(other in fresh)))
402
403 return {
404 ...held,
405 moot,
406 set: kind === undefined || set === undefined || isAside ? kept : { ...kept, [kind]: set },
407 isChanged: held.isChanged || SOURCING.includes(head) || raws.some(raw => ENV_DRIVE.test(raw)),
408 shots: [...held.shots, ...mine, ...[...new Set([...quoted, ...loose])].filter(other => other !== kind).map(other => ({ kind: other, why: INDIRECT }))],
409 }
410}
411
412// What a subshell sets ends with it, but what it wrote to a config file stays written: a context or project it chose, and a workspace
413// when it chose it in the folder the reading returns to. PowerShell's parentheses group and do not scope, so there the reading carries on.
414const scoped = (held: Reading, mark: string, isScoping: boolean): Reading => {
415 if (!isScoping) return held
416 if (mark === OPEN) return { ...held, outer: [...held.outer, held] }
417 const before = held.outer.at(-1)
418 if (before === undefined) return held
419 const { tf: _tf, ...written } = held.set
420 const set: Marks = held.dir === before.dir ? held.set : { ...written, ...(before.set.tf === undefined ? {} : { tf: before.set.tf }) }
421
422 return { ...before, shots: held.shots, moot: held.moot, set }
423}
424
425// A backslash before a newline (a backtick in PowerShell) carries the command on, so the next line's flags are this command's.
426const shotsOf = (command: string, { cwd, isWindows }: Place, isScoping: boolean): Shot[] =>
427 segmentsOf(isScoping ? spoken(command).replace(CONTINUED, '') : spoken(command).replace(TICK_CONTINUED, ' '), isScoping).reduce<Reading>(
428 (held, raws) => (raws.length === 1 && (raws[0] === OPEN || raws[0] === SHUT) ? scoped(held, raws[0], isScoping) : stepOf(held, raws, isWindows, isScoping)),
429 { over: {}, dir: cwd, set: {}, moot: {}, isChanged: false, shots: [], outer: [] },
430 ).shots
431
432const envOf = async ($: EngineInterface): Promise<Env> => ({
433 AWS_PROFILE: await $.env.get('AWS_PROFILE'),
434 AWS_DEFAULT_PROFILE: await $.env.get('AWS_DEFAULT_PROFILE'),
435 AWS_ACCESS_KEY_ID: await $.env.get('AWS_ACCESS_KEY_ID'),
436 CLOUDSDK_CORE_PROJECT: await $.env.get('CLOUDSDK_CORE_PROJECT'),
437 TF_WORKSPACE: await $.env.get('TF_WORKSPACE'),
438 TF_DATA_DIR: await $.env.get('TF_DATA_DIR'),
439 DATABASE_URL: await $.env.get('DATABASE_URL'),
440 PGHOST: await $.env.get('PGHOST'),
441 PGDATABASE: await $.env.get('PGDATABASE'),
442 PGSERVICE: await $.env.get('PGSERVICE'),
443 PGHOSTADDR: await $.env.get('PGHOSTADDR'),
444 USERPROFILE: await $.env.get('USERPROFILE'),
445 HOME: await $.env.get('HOME'),
446})
447
448const probed = ($: EngineInterface, argv: readonly string[], dir: string, isWindows: boolean, probes: Probes): Promise<string> => {
449 const [tool = ''] = argv
450 const asked =
451 probes.get(tool) ??
452 $.process.run(isWindows ? [...SHELL, ...argv] : argv, { cwd: dir, timeoutMs: PROBE_MS }).then(
453 ran => (ran.exitCode === 0 ? (ran.stdout.split('\n')[0] ?? '').trim() : ''),
454 () => '',
455 )
456 probes.set(tool, asked)
457
458 return asked
459}
460
461// The CLI's versions do not agree on which of the two names wins, so two different ones name no profile.
462const isSplit = (env: Env): boolean => !!env.AWS_PROFILE && !!env.AWS_DEFAULT_PROFILE && env.AWS_PROFILE !== env.AWS_DEFAULT_PROFILE
463
464const ambient = async ($: EngineInterface, shot: Ask, env: Env, place: Place, probes: Probes): Promise<Target | undefined> => {
465 const { kind, dir, over } = shot
466 const phrase = PHRASES[kind]
467 const from = (name: string): string => (name in over ? NAMED : ENVIRONMENT)
468 if (kind === 'kube') {
469 const name = await probed($, KUBECTL, dir ?? place.cwd, place.isWindows, probes)
470
471 return name === '' ? undefined : { name, phrase, source: '' }
472 }
473 if (kind === 'gcp') {
474 if (env.CLOUDSDK_CORE_PROJECT) return { name: env.CLOUDSDK_CORE_PROJECT, phrase, source: from('CLOUDSDK_CORE_PROJECT') }
475 const name = await probed($, GCLOUD, dir ?? place.cwd, place.isWindows, probes)
476
477 return name === '' || name === '(unset)' ? undefined : { name, phrase, source: '' }
478 }
479 if (kind === 'aws') {
480 if (env.AWS_ACCESS_KEY_ID) return { name: `key …${env.AWS_ACCESS_KEY_ID.slice(-4)}`, phrase: 'aws key', source: from('AWS_ACCESS_KEY_ID') }
481 if (isSplit(env)) return undefined
482 if (env.AWS_PROFILE) return { name: env.AWS_PROFILE, phrase, source: from('AWS_PROFILE') }
483 if (env.AWS_DEFAULT_PROFILE) return { name: env.AWS_DEFAULT_PROFILE, phrase, source: from('AWS_DEFAULT_PROFILE') }
484 const home = env.USERPROFILE || env.HOME
485 const isSetUp = home !== undefined && home !== '' && ((await $.fs.exists(`${home}/.aws/config`)) || (await $.fs.exists(`${home}/.aws/credentials`)))
486
487 return isSetUp ? { name: 'default', phrase, source: '' } : undefined
488 }
489 if (kind === 'tf') {
490 if (env.TF_WORKSPACE) return { name: env.TF_WORKSPACE, phrase, source: from('TF_WORKSPACE') }
491 if (dir === undefined || env.TF_DATA_DIR) return undefined
492 const name = await $.fs.read(`${dir}/.terraform/environment`).then(
493 text => text.trim(),
494 () => '',
495 )
496 if (name !== '') return { name, phrase, source: '' }
497
498 return (await $.fs.exists(`${dir}/.terraform.lock.hcl`)) ? { name: 'default', phrase, source: '' } : undefined
499 }
500
501 const reach = reachOf(env.DATABASE_URL ?? '')
502 if (reach !== undefined) return { name: reach, phrase, source: from('DATABASE_URL') }
503
504 return env.PGHOST ? { name: env.PGDATABASE ? `${env.PGHOST}/${env.PGDATABASE}` : env.PGHOST, phrase, source: from('PGHOST') } : undefined
505}
506
507const settle = async ($: EngineInterface, shot: Ask, base: Env, place: Place, probes: Probes): Promise<Target | string> => {
508 const { kind, over } = shot
509 const env = { ...base, ...over }
510 const phrase = PHRASES[kind]
511 const isUnset = (names: readonly string[]): boolean => names.some(name => over[name] === UNSET)
512 if (shot.ask === 'url') {
513 const reach = isUnset(['DATABASE_URL']) ? undefined : reachOf(env.DATABASE_URL ?? '')
514
515 return reach === undefined ? FROM_VARIABLE : { name: reach, phrase, source: 'DATABASE_URL' in over ? NAMED : ENVIRONMENT }
516 }
517 if (shot.ask === 'psql') {
518 if (env.PGSERVICE || env.PGHOSTADDR) return GIVEN
519 if (shot.base === UNSET || isUnset(['PGHOST', 'PGDATABASE'])) return FROM_VARIABLE
520 // psql itself does not read DATABASE_URL, so only PGHOST moves it off its default.
521 const host = env.PGHOST || 'localhost'
522 const base = shot.base ?? env.PGDATABASE
523
524 return { name: base ? `${host}/${base}` : host, phrase, source: !env.PGHOST ? 'psql default' : 'PGHOST' in over ? NAMED : ENVIRONMENT }
525 }
526 if (isUnset(VARIABLES[kind])) return FROM_VARIABLE
527 if (kind === 'aws' && !env.AWS_ACCESS_KEY_ID && isSplit(env)) return 'two profiles set'
528 if (kind === 'tf' && !env.TF_WORKSPACE && env.TF_DATA_DIR) return 'another data folder given'
529 const found = await ambient($, shot, env, place, probes)
530 if (found !== undefined) return found
531 if (kind === 'kube' || kind === 'gcp') return `${kind === 'kube' ? 'kubectl' : 'gcloud'} did not answer`
532 if (kind === 'aws') return 'no profile found'
533
534 return shot.dir === undefined ? 'folder changed in the command' : 'no workspace found here'
535}
536
537const merged = (rows: readonly AimRow[], seen: ReadonlyMap<AimKind, string | undefined>, now: number): AimRow[] =>
538 KINDS.flatMap(kind => {
539 const held = rows.find(row => row.kind === kind)
540 if (!seen.has(kind)) return held ?? []
541 const name = seen.get(kind)
542 if (name === undefined) return []
543
544 return [{ kind, name, since: held === undefined || held.name === name ? (held?.since ?? 0) : now }]
545 })
546
547const isHot = (name: string, held: readonly string[]): boolean => held.some(word => name.toLowerCase().includes(word))
548
549const tidied = (command: string): string =>
550 command
551 .replace(/:\/\/[^\s/]*@([^\s/:?#"']*)(?::\d+)?([^\s?#"']*)(?:[?#][^\s"']*)?/g, '://…@$1$2')
552 .replace(/\b(\w*(?:PASSWORD|SECRET|TOKEN)\w*)=\S+/gi, '$1=…')
553 .replace(/(?<![\w-])(-{1,2}(?!-?no-)[\w-]*(?:password|secret|token)[\w-]*)\s+(?!-)\S+/gi, '$1 …')
554 .replace(/\s+/g, ' ')
555 .trim()
556 .slice(0, COMMAND_MAX)
557
558const aimed = async ($: EngineInterface, command: string, isScoping: boolean): Promise<string | undefined> => {
559 if (!MENTIONED.test(command)) return undefined
560
561 const cwd = await $.session.cwd()
562 const place = { cwd, isWindows: WINDOWS.test(cwd) }
563 const shots = shotsOf(command, place, isScoping)
564 if (shots.length === 0) return undefined
565
566 const env = shots.some(shot => 'ask' in shot) ? await envOf($) : {}
567 const probes: Probes = new Map()
568 const seen = new Map<AimKind, string | undefined>()
569 const settled: Settled[] = []
570 for (const shot of shots) {
571 if (!('ask' in shot)) {
572 settled.push(shot)
573 continue
574 }
575 const found = await settle($, shot, env, place, probes)
576 const isAmbient = shot.ask === 'ambient' && shot.dir === cwd && !VARIABLES[shot.kind].some(name => name in shot.over)
577 if (isAmbient) seen.set(shot.kind, typeof found === 'string' ? undefined : found.name)
578 settled.push(typeof found === 'string' ? { kind: shot.kind, why: found } : { kind: shot.kind, ...found })
579 }
580
581 const now = await $.clock.now()
582 const held = await read($, words)
583 const rows = merged((await read($, aim)).rows, seen, now)
584 const lines = settled.map(shot => {
585 if ('why' in shot) return { text: `${PHRASES[shot.kind]} not known: ${shot.why}`, isProduction: false, isKnown: false }
586 const since = shot.source !== NAMED && seen.get(shot.kind) === shot.name ? (rows.find(row => row.kind === shot.kind)?.since ?? 0) : 0
587 const said = [shot.phrase, shot.source, since === 0 ? '' : `changed, seen ${ago(now - since)} ago`].filter(part => part !== '').join(', ')
588 const isProduction = isHot(shot.name, held)
589
590 return { text: `${isProduction ? 'PRODUCTION: goes' : 'Goes'} to ${shot.name} (${said})`, isProduction, isKnown: true }
591 })
592 const texts = [...new Set(lines.map(line => line.text))]
593 const line = `${texts.slice(0, LINES_MAX).join('; ')}${texts.length > LINES_MAX ? ` (+${texts.length - LINES_MAX} more)` : ''}`
594 const last: AimShot = { command: tidied(command), line, isProduction: lines.some(made => made.isProduction), isKnown: lines.every(made => made.isKnown) }
595 if (!(await read($, isOn))) return undefined
596 await update($, aim, kept => ({ ...(kept ?? BLANK), rows: merged((kept ?? BLANK).rows, seen, now), last }))
597
598 return line
599}
600
601const judged = async <Verdict extends { decision: string; reason?: string }>(
602 $: EngineInterface,
603 input: unknown,
604 id: string | undefined,
605 verdict: Verdict,
606 isScoping: boolean,
607): Promise<Verdict> => {
608 const command = (input as { command?: unknown } | null)?.command
609 if (verdict.decision !== 'ask' || typeof id !== 'string' || typeof command !== 'string') return verdict
610
611 const line = await aimed($, command, isScoping)
612 if (line === undefined) return verdict
613
614 return { ...verdict, reason: verdict.reason ? `${line} · ${verdict.reason}` : line }
615}
616
617const refresh = async ($: EngineInterface): Promise<void> => {
618 const cwd = await $.session.cwd()
619 const place = { cwd, isWindows: WINDOWS.test(cwd) }
620 const env = await envOf($)
621 const probes: Probes = new Map()
622 const found = await Promise.all(KINDS.map(kind => ambient($, { kind, ask: 'ambient', dir: cwd, over: {} }, env, place, probes)))
623 const seen = new Map(KINDS.map((kind, at) => [kind, found[at]?.name]))
624 const now = await $.clock.now()
625 if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), rows: merged((kept ?? BLANK).rows, seen, now) }))
626}
627
628const tick = async ($: EngineInterface): Promise<void> => {
629 if (!(await read($, isOn))) return
630
631 const { at, isBusy } = await read($, aim)
632 if (isBusy || (at !== 0 && (await $.clock.now()) - at < REFRESH_MS)) return
633
634 await update($, aim, kept => ({ ...(kept ?? BLANK), isBusy: true }))
635 try {
636 await refresh($)
637 } finally {
638 const ended = await $.clock.now()
639 if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), at: ended, isBusy: false }))
640 }
641}
642
643const sync = async ($: EngineInterface): Promise<void> => {
644 const isWanted = await read($, isOn)
645 if (isWanted && timer === undefined) {
646 timer = $.clock.every(TICK_MS, () => {
647 void tick($).catch(() => undefined)
648 })
649 }
650 if (!isWanted && timer !== undefined) {
651 timer.cancel()
652 timer = undefined
653 }
654}
655
656const listed = (held: readonly string[]): string => (held.length === 0 ? 'No production words.' : `Production words: ${held.join(', ')}.`)
657
658const add = async ($: EngineInterface, word: string): Promise<string> => {
659 const held = await read($, words)
660 if (held.includes(word)) return listed(held)
661 if (held.length >= WORDS_MAX) return `Aim holds ${WORDS_MAX} production words; unprod one first.`
662
663 const kept = await update($, words, () => [...held, word])
664 await $.store.set('words', kept)
665
666 return listed(kept)
667}
668
669const remove = async ($: EngineInterface, word: string): Promise<string> => {
670 const held = await read($, words)
671 if (!held.includes(word)) return `"${word}" is not a production word.`
672
673 const kept = await update($, words, () => held.filter(other => other !== word))
674 await $.store.set('words', kept)
675
676 return listed(kept)
677}
678
679const told = async ($: EngineInterface): Promise<string> => {
680 const { at, rows, last } = await read($, aim)
681
682 return [
683 rows.length === 0 ? (at === 0 ? LOOKING : NONE) :`Targets: ${rows.map(row => `${row.kind} ${row.name}`).join(', ')}.`,
684 listed(await read($, words)),
685 ...(last === null ? [] : [`Last: ${last.command}`, last.line]),
686 ].join('\n')
687}
688
689const clear = async ($: EngineInterface): Promise<string> => {
690 await update($, aim, kept => ({ ...(kept ?? BLANK), rows: (kept ?? BLANK).rows.map(row => ({ ...row, since: 0 })), last: null }))
691
692 return 'Aim cleared.'
693}
694
695const rowOf = (row: AimRow, at: number, width: number): string => {
696 const inner = width - 4
697 if (width < WIDE_COLUMNS) return cut(`${row.kind} ${row.name}`, inner)
698
699 const left = `${row.kind.padEnd(5)} ${row.name}`
700 const right = row.since === 0 ? '' : `changed ${ago(at - row.since)}`
701
702 return right === '' ? cut(left, inner) : `${cut(left, inner - right.length - 2).padEnd(inner - right.length)}${right}`
703}
704
705const show = async (
706 $: EngineInterface,
707 { Box, Text }: Tags,
708 beneath: RenderElement,
709 place: WidgetsPlace,
710 columns: number,
711): Promise<RenderElement> => {
712 if (!(await read($, isOn))) return beneath
713 if ((await $.state.get(site)).value !== place) return beneath
714
715 const width = fit((await $.state.get(widths)).value?.['aim-widget'] ?? CARD_COLUMNS, columns)
716 const inner = width - 4
717 const { at, rows, last } = await read($, aim)
718 const held = await read($, words)
719 const isBare = rows.length === 0 && last === null
720 const isProduction = last?.isProduction === true || rows.some(row => isHot(row.name, held))
721 const tone = last === null ? undefined : last.isProduction ? 'red' : last.isKnown ? undefined : 'yellow'
722
723 return $.widgets.card({
724 beneath,
725 width,
726 title: 'Aim',
727 note: isProduction ? 'prod' : last?.isKnown === false ? 'not known' : rows.length === 0 ? undefined : plural(rows.length, 'target'),
728 body: (
729 <Box key="aim" flexDirection="column">
730 {(isBare ? wrapped(at === 0 ? LOOKING : EMPTY, inner) : []).map((line, index) => (
731 <Text key={`say-${index}`} dimColor wrap="truncate-end">
732 {line}
733 </Text>
734 ))}
735 {rows.map(row => (
736 <Text key={`row-${row.kind}`} color={isHot(row.name, held) ? 'red' : undefined} wrap="truncate-end">
737 {rowOf(row, at, width)}
738 </Text>
739 ))}
740 {last !== null && (
741 <Text key="command" dimColor wrap="truncate-end">
742 {cut(last.command, inner)}
743 </Text>
744 )}
745 {(last === null ? [] : wrapped(last.line, inner)).map((line, index) => (
746 <Text key={`line-${index}`} color={tone} wrap="truncate-end">
747 {line}
748 </Text>
749 ))}
750 </Box>
751 ),
752 })
753}
754
755export const register: Register = on => {
756 on('session.start', async ($, e, next) => {
757 await $.command.register({
758 name: 'aim-widget',
759 description: 'Toggle the Aim card, or set the words that mark a target as production',
760 argumentHint: '[on|off|prod <word>|unprod <word>|show|clear]',
761 })
762 if ((await $.store.get('isOn')) === true) await update($, isOn, () => true)
763 const saved = await $.store.get('words')
764 if (Array.isArray(saved)) await update($, words, () => saved.filter(word => typeof word === 'string' && WORD.test(word)).slice(0, WORDS_MAX))
765 if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), isBusy: false }))
766 await sync($)
767
768 return next(e)
769 })
770
771 on('command.run', { command: 'aim-widget' }, async ($, e) => {
772 const [first = '', second = '', ...others] = e.args.trim().split(/\s+/)
773 const verb = first.toLowerCase()
774
775 if (verb === 'prod' || verb === 'unprod' || verb === 'show' || verb === 'clear') {
776 const word = second.toLowerCase()
777 const isWorded = verb === 'prod' || verb === 'unprod'
778 if (isWorded ? !WORD.test(word) || others.length > 0 : second !== '') return { text: USAGE }
779 if (!(await read($, isOn))) return { text: OFF }
780 if (isWorded) return { text: verb === 'prod' ? await add($, word) : await remove($, word) }
781
782 return { text: verb === 'show' ? await told($) : await clear($) }
783 }
784
785 const arg = e.args.trim().toLowerCase()
786 if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: USAGE }
787
788 const isShown = await update($, isOn, shown => (arg === '' ? !(shown ?? false) : arg === 'on'))
789 await $.store.set('isOn', isShown)
790 if (!isShown) await update($, aim, () => BLANK)
791 await sync($)
792
793 return { text: isShown ? 'Aim on; /widgets places it.' : 'Aim off.' }
794 })
795
796 on('tool.check', { tool: 'Bash' }, async ($, e, next) => ((await read($, isOn)) ? judged($, e.input, e.tool_use_id, await next(e), true) : next(e)))
797
798 on('tool.check', { tool: 'PowerShell' }, async ($, e, next) => ((await read($, isOn)) ? judged($, e.input, e.tool_use_id, await next(e), false) : next(e)))
799
800 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
801 show($, $.ui.resolve(e), await next(e), 'side', e.props.bodyColumns),
802 )
803
804 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
805 e.props.hasSurvey ? next(e) : show($, $.ui.resolve(e), await next(e), 'above', e.props.bodyColumns),
806 )
807
808 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
809 show($, $.ui.resolve(e), await next(e), 'below', e.viewport?.columns ?? 80),
810 )
811}
812hooks/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 23 lines1export type AimSwitch = boolean
2
3export type AimKind = 'kube' | 'aws' | 'gcp' | 'tf' | 'db'
4
5export type AimRow = { kind: AimKind; name: string; since: number }
6
7export type AimShot = { command: string; line: string; isProduction: boolean; isKnown: boolean }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'aim-widget': {
12 isOn: AimSwitch
13 words: string[]
14 aim: {
15 at: number
16 isBusy: boolean
17 rows: AimRow[]
18 last: AimShot | null
19 }
20 }
21 }
22}
23