SLOPSHOPPER

hud

Two coloured, animated lines under the prompt, every figure named: model, effort, context window, rate limits, turn timer, tool calls, agents, git state…

newbandspinnerguardtoastprocess
★ 1v0.1.0no licenseupdated 2026-10-08seanrobertwright/claude-mods/mods/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › model opus 5.5 context ████░░░░ 49% 5h limit 31% tools 9 this turn · 9 total 🪾 worktree app cost $0.42 session 30m folder app ⟨Claude Code's own drawing⟩

Draws

Prompt hint
model opus 5.5 context ████░░░░ 49% 5h limit 31% tools 9 this turn · 9 total 🪾 worktree app cost $0.42 session 30m folder app ⟨Claude Code's own drawing⟩
README

🧩 claude-mods

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a .env or key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, and one dialog for every mod's settings.

Claude Code 2.1.289+ 20 mods TypeScript strict Checks: tsc, ESLint, validate, test

claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty, or read the source and write your own.

What is a mod?

A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.

Runs asWho decides when it acts
ModTypeScript function hooks inside Claude CodeThe mod's own code
SkillInstructions the model readsThe model
Plain pluginCommands, agents or shell hooksYou, or a shell script

The mods

ModWhere it showsWhat it does
🧭 whats-nextPane in the side panelLists the next steps of your workflow, each with a prompt ready to paste
⚡ quick-replyBand above the promptOne-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket
⏳ auto-resumeBand and status lineCounts down to a rate limit's reset, then sends "continue"
🐙 github-panelPane in the side panelThe repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red
📚 shelfBand above the promptNamed folders and files; one click drops a path into what you are typing
🔔 turn-chimeSound and toastTells you when a long turn ends or Claude stops to ask you something
🔒 open-file-guardQuestion dialogAsks you to close a Word, Excel or PowerPoint file before Claude uses it
🌿 branch-guardQuestion dialogAsks you before Claude commits or pushes on the default branch
🔑 env-guardQuestion dialogAsks you before Claude reads a .env or key file, and refuses when no one is there to answer
🩹 bash-quoting-rescueRefused tool callStops a shell command that does not parse, such as an unclosed quote, before any of it runs
📂 outputsPane in the side panelThe files this session made or changed, newest first; click one to open it
🔎 sourcesPane in the side panelThe files Claude read, grouped by where they came from, with a lock to the project folder
📊 hudTwo lines under the promptModel, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour
🧹 post-merge-cleanupQuestion dialog and toastAfter a PR merges, /cleanup switches to the default branch, pulls and deletes the branch
🧾 pre-pr-claims-checkRefusal Claude readsRefuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count
🚦 lint-test-gateBand above the promptRuns your checks on a press and before Claude's git commit, and hands back only the failures
🔄 chrome-tab-self-healNote after a browser tool's errorWhen a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again
🏷 session-auto-namerBand above the promptSuggests a name for the session from its first prompt; one press renames it
🧠 model-effort-presetsBand above the promptPlan and execute presets: one press switches the model and the effort together
⚙ mod-settingsGear on each pane; a dialogChange and save any mod's settings without leaving the session

When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.

🧭 whats-next

A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.

What's next                        refresh
updated 3 min ago

Fix the failing parse test
working on it  done
The check is red, so nothing else can merge.

Open a pull request for the fix
Review comes before the next feature starts.

Triage the two new issues
They arrived while you were heads-down.
flowchart LR
    A[Skill answers<br/>what's next] --> B[Steps in the pane]
    B --> C[You read a step's prompt<br/>and send it yourself]
    C --> D[Step glows:<br/>working on it]
    D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
    E -- not yet --> D
    E -- yes --> F[Step leaves the list]
  • You stay in charge. A step's prompt reaches the model only when you read it and send it yourself. Click a step to see its prompt, then paste it, paste it into a fresh session (after your prime command, if you name one), or copy it.
  • It notices when you are done. After each answered turn, a System One model is asked whether the step is finished, as your System One models setting allows, and Haiku when none answers surely enough. A finished step leaves the list. Press d to drop it yourself.
  • The run that asks is read-only. By default it gets only read commands of git and gh plus Read, Glob and Grep. git push, git config, git -c, gh api and --output are always denied.
Command or keyWhat it does
/whats-nextBring the pane to the front
/whats-next refreshAsk the skill again
rRefresh
1 to 9Show that step's prompt in the pane
p, n, cPaste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it
bBack to the list
dMark the active step done
SettingKeyDefaultMeaning
Skillskill/ask-seanThe skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or -
Most stepsmaxSteps5How many steps to ask for (1-9)
Refresh on startrefreshOnStartonAsk for a fresh list when a session starts in a git repository
Tools the headless run may useallowedToolsempty: the read-only setComma-separated permission rules for the headless run
Modelmodelempty: your defaultModel for the headless run, as an alias (haiku) or a full id
Prime commandprimeCommandempty: noneA slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so
System One modelsmodelChoicelocal onlyWhich System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable)
Jev API keyjevApiKeyemptyYour key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev
Laya portlayaPort8000The port your laya-serve listens on at 127.0.0.1 (1-65535)

/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):

echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin

The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.

  • A list you write replaces that set; it does not add to it. To widen the set, write all of it plus your additions.
  • Each rule is a tool name, optionally followed by (...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.
  • The run can always call Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.

What the judge sends to a System One model, once after each answered turn while a step is active:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the active step's title, reason and prompt and the last 8,000 characters of Claude's answer. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the same, fitted to Laya's small window. The step's prompt is dropped first, then the start of the answer, so the end of the answer, where Claude says whether the work is done, is kept.
  • Sure answers only. "Done" counts at a probability of 0.9 or more and "not done" at 0.1 or less. Anything between, a model that is busy or fails, or no answer within 5 s leaves the step to Haiku, as before.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a line in the pane, and the list stays. A rejected key stays off until the mod reloads.
  • A changed key takes effect once the mod reloads. Saving it in the settings dialog reloads the mod; a key changed any other way waits for the next start of Claude Code.

Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.

⚡ quick-reply

One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.

Reply:  [a: Keep the copies]  [b: Add a sync script]  [Yes]  [Go with your recommendation]  [No]
  • A choice's button sends its marker with its label, so the model cannot misread it.
  • A numbered report before a yes-or-no question ("Shall I commit?") is not offered as choices.
  • When the answer recommends something, the "recommend" reply is the highlighted one. Advice against something does not count.
  • The band stays out of the way while Claude is working, and after a subagent's turn.
  • A model can read the ending too. After each answered turn, a System One model is asked how the answer ends, as your System One models setting allows: whether it asks you something, whether its numbered items are choices, and which one it recommends. The band shows its own reading at once, and a sure answer that arrives within 2 s takes its place, so a numbered report closed by "push now or wait?" is not offered as choices.

Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:

Reply:  [Pass]  [Fail…]  [Skip]

Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.

Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:

Reply:  [Next ticket: /wayfinder 135]  [Continue]  [Commit and push]

One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.

SettingKeyDefaultMeaning
Replies to a questionquestionReplies`Yes\Go with your recommendation\No`Shown after Claude asks something, separated by `\`; empty shows only the choices Claude offered
Replies otherwiseidleReplies`Continue\Commit and push`Shown after any other answer; empty hides the band then, except for Next ticket
Offer the next wayfinder ticketwayfinderNexttrueAfter a wayfinder turn closes a ticket or charts a map, offer Next ticket
System One modelsmodelChoicelocal onlyWhich System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable)
Jev API keyjevApiKeyemptyYour key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev
Laya portlayaPort8000The port your laya-serve listens on at 127.0.0.1 (1-65535)

Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):

echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin

What the band sends to a System One model, once after each answered turn:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the last 8,000 characters of Claude's answer and the labels of the choices found in it. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the answer's closing lines that fit Laya's small window, where the question is, and at most 9 labels of at most 40 characters.
  • Sure answers only. Each part of the reading counts only when the model is sure of it, at 0.9 or more (0.1 or less for "these are not choices"). Anything less, a model that is busy or fails, a reading Laya reports as cut, or no answer within 2 s leaves the band's own reading. So does a reading that arrives after your next prompt.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a dim line under the replies, until your next prompt. A rejected key stays off until the mod reloads.

Laya and a Jev key are optional: with neither, the band reads each answer as before.

⏳ auto-resume

When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.

⏳ rate limited:  sending "continue" in 1 h 12 min  [Resume now]  [Cancel]
  • The countdown shows above the prompt and in the status line.
  • Send a prompt of your own and it steps aside: you took over.
  • It gives up after too many resumes in a row without a successful answer.
CommandWhat it does
/auto-resumeSay what is waiting, if anything
/auto-resume nowSend the resume now
/auto-resume cancelCancel the wait
/auto-resume in <minutes>Schedule a resume yourself (1 to 1440)
SettingKeyDefaultMeaning
Resume prompttextcontinueWhat is sent when the wait is over; empty sends continue
Grace after reset (s)graceSeconds60Extra seconds to wait past the limit's reset time (0-900)
Retry overloaded/server errorsretryOverloadedonAlso resume after an overloaded or server error, backing off from one minute
Most retries in a rowmaxRetries5Give up after this many resumes without a successful answer (1-20)

For example, to send a longer prompt and wait two minutes past the reset (how to set it):

echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin

🐙 github-panel

A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.

octocat/hello-world                refresh
updated just now

Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next

Pull requests 2                        all
#41 Add a drift check for copied guards
  draft · @octocat
#40 Bring the asked pane to the front
  ✗ checks failing · @hubot           fix

Issues 2                               all
#39 Share the headless-session check
  blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
  needs-triage · @hubot implement wayfinder
  • An issue blocked by an open issue has a red line under it. Hover it to see what blocks it.
  • In a narrow pane the implement and wayfinder buttons take a line of their own.
  • The lists refresh on a timer, after a turn once they are a minute old, and on r.
  • The pull request of the branch you are on is watched: when its checks turn green or red, a toast says "Checks passed on #40" or "Checks failed on #40". The first refresh, and the first after you switch branch, only notes where the checks stand.
  • While that pull request's checks fail, its row has a fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone.
  • Running /wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.
  • The pinned issue's sub-issues are its tickets, each counted once: closed ones are done, open ones with an open blocker are blocked, assigned ones are claimed, and the rest are takeable. next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.
  • work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.
  • The pinned issue is read in the same refresh as the lists. When a refresh fails, the last section stays and the error shows.
Command or keyWhat it does
/githubBring the pane to the front and refresh it
rRefresh
allOpen the whole list on GitHub
implement, wayfinderFill the command and the issue's URL into the prompt, without sending it
fixFill a request to fix the current branch's failing checks into the prompt box
unpinDrop the pinned issue
work nextFill /wayfinder and the pinned issue's URL into the prompt, without sending it
SettingKeyDefaultMeaning
Most items per listlimit30How many open pull requests and issues to list each (1-100)
Refresh every (minutes)refreshMinutes5How often to refresh (0-120); 0 refreshes only on open, after turns and on r
Implement button fillsimplementCommand/implementThe slash command the implement button fills before the issue's URL; empty hides the button
Wayfinder button fillswayfinderCommand/wayfinderThe slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off

A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.

For example, to list fifty of each and stop the timer (how to set it):

echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin

Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.

📚 shelf

A row of named folders and files above the prompt, for the paths you type again and again. Click a name and its path lands at the cursor in what you are typing. Nothing is sent.

Shelf:  [brand]  [records]  [kb]
  • A path with a space arrives in double quotes, ready to use.
  • The shelf is the same in every project folder and every session.
  • When the names do not fit one row, the band shows those that do and counts the rest; /shelf always lists them all.
CommandWhat it does
/shelfList the shelf with each path
/shelf add <name> [path]Put a path on the shelf; with no path, the project folder
/shelf remove <name>Take one off

The shelf has no settings: you fill it with /shelf add.

/shelf add brand "N:/Marketing/Brand Kit"
/shelf add records N:/RECORDS
/shelf add kb
/shelf remove brand
  • A name is one word of up to 24 characters. Adding a name already on the shelf, whatever its capitals, replaces that entry where it stands.
  • The path is k
Source 3 files
hooks/register.tsx 264 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Git, HudView, Limit } from '../types'
5import { limitsToWarn, limitWarning, parseConfig, parseGit, parseWorktree, rows } from './hud'
6import type { Config } from './hud'
7
8/** How often the colours move while a turn runs. */
9const FRAME_MS = 150
10/** How often the figures are read again while a turn runs, in frames, and while idle. */
11const WORKING_REFRESH_FRAMES = 40
12const IDLE_REFRESH_MS = 30_000
13const GIT_TIMEOUT_MS = 5_000
14/** The row's room where the surface does not say. */
15const DEFAULT_COLUMNS = 100
16
17const EMPTY: HudView = {
18  model: '',
19  effort: null,
20  contextPercent: null,
21  limits: [],
22  costUsd: null,
23  startedAt: 0,
24  git: null,
25  worktree: null,
26  folder: '',
27  agents: 0,
28  toolsTurn: 0,
29  toolsSession: 0,
30  isWorking: false,
31  turnStartedAt: 0,
32}
33const view = atom({ plugin: 'hud', key: 'view' } as const, EMPTY)
34const frame = atom({ plugin: 'hud', key: 'frame' } as const, 0)
35const warned = atom({ plugin: 'hud', key: 'warned' } as const, {} as Record<string, string | null>)
36
37// Die with the module on a reload; session.start, the next attach or the next turn starts them again.
38let moving: Timer | undefined
39let idle: Timer | undefined
40
41/**
42 * Whether any surface shows the session right now. Asked before each action
43 * the mod starts on its own and never kept. Each mod carries its own copy (ADR-0001).
44 */
45async function isShown($: EngineInterface): Promise<boolean> {
46  return (await $.session.surfaces()).length > 0
47}
48
49/** The working tree's state, and the top folder of the worktree the session is in. */
50async function readGit($: EngineInterface): Promise<{ git: Git | null; worktree: string | null }> {
51  const [status, where] = await Promise.all(
52    [
53      ['git', 'status', '--porcelain=v2', '--branch'],
54      ['git', 'rev-parse', '--show-toplevel'],
55    ].map(argv => $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)),
56  )
57  return {
58    git: status === undefined || status.exitCode !== 0 ? null : parseGit(status.stdout),
59    worktree: where === undefined || where.exitCode !== 0 ? null : parseWorktree(where.stdout),
60  }
61}
62
63/**
64 * Toasts each rate limit that has reached the red level, once in its window. The limits due are worked out
65 * inside the update, so two refreshes at once cannot both toast the same one.
66 */
67async function warnLimits($: EngineInterface, limits: readonly Limit[]): Promise<void> {
68  const now = await $.clock.now()
69  let due: Limit[] = []
70  await update($, warned, held => {
71    due = limitsToWarn(limits, held, now)
72    return due.length === 0 ? held : { ...held, ...Object.fromEntries(due.map(limit => [limit.kind, limit.resetsAt])) }
73  })
74  for (const limit of due) $.ui.toast(limitWarning(limit, now))
75}
76
77/** Reads the figures again, and warns of a rate limit in the red. Git costs a process, so it is read only when asked for. */
78async function refresh($: EngineInterface, withGit: boolean): Promise<void> {
79  const [usage, model, folder, agents] = await Promise.all([
80    $.session.usage(),
81    $.session.model().catch(() => ''),
82    $.session.cwd().catch(() => ''),
83    $.agent.list().catch(() => []),
84  ])
85  const repo = withGit ? await readGit($) : undefined
86  const limits = usage.rateLimits.map((limit): Limit => ({ kind: limit.kind, percent: limit.percentUsed, resetsAt: limit.resetsAt ?? null }))
87  await update($, view, (current): HudView => ({
88    ...current,
89    model,
90    folder,
91    contextPercent: usage.context.percent ?? null,
92    limits,
93    costUsd: usage.cost?.usd ?? null,
94    startedAt: usage.startedAt,
95    agents: agents.filter(agent => agent.status === 'running').length,
96    git: repo === undefined ? current.git : repo.git,
97    worktree: repo === undefined ? current.worktree : repo.worktree,
98  }))
99  await warnLimits($, limits)
100}
101
102function stopMoving(): void {
103  moving?.cancel()
104  moving = undefined
105}
106
107function stopIdle(): void {
108  idle?.cancel()
109  idle = undefined
110}
111
112/** Moves the row's colours and its turn timer while a turn runs and a surface shows it; a beat that finds neither stops. */
113function startMoving($: EngineInterface): void {
114  if (moving !== undefined) return
115  const own = $.clock.every(FRAME_MS, () => {
116    void (async () => {
117      if (!(await read($, view)).isWorking || !(await isShown($))) {
118        if (moving === own) stopMoving()
119        return
120      }
121      const beat = (await read($, frame)) + 1
122      await update($, frame, () => beat)
123      if (beat % WORKING_REFRESH_FRAMES === 0) await refresh($, false)
124    })().catch(() => undefined)
125  })
126  moving = own
127}
128
129/** Reads the figures again now and then while nothing else does; a beat that finds no surface stops until the next attach. */
130function startIdle($: EngineInterface): void {
131  if (idle !== undefined) return
132  const own = $.clock.every(IDLE_REFRESH_MS, () => {
133    void (async () => {
134      if (!(await isShown($))) {
135        if (idle === own) stopIdle()
136        return
137      }
138      await refresh($, !(await read($, view)).isWorking)
139      // The session length and the time to a reset move on even when no figure changed.
140      await update($, frame, beat => beat + 1)
141    })().catch(() => undefined)
142  })
143  idle = own
144}
145
146async function wake($: EngineInterface): Promise<void> {
147  if (!(await isShown($))) return
148  startIdle($)
149  await refresh($, true)
150}
151
152export const register: Register = (on, options) => {
153  const config: Config = parseConfig(options)
154
155  on('session.start', async ($, e, next) => {
156    const started = await next(e)
157    stopMoving()
158    stopIdle()
159    // A headless session does nothing until a surface attaches.
160    await wake($).catch(() => undefined)
161    return started
162  })
163
164  on('session.attach', async ($, e, next) => {
165    const attached = await next(e)
166    await wake($).catch(() => undefined)
167    return attached
168  })
169
170  on('turn.start', async ($, e, next) => {
171    if (await isShown($).catch(() => false)) {
172      const now = await $.clock.now()
173      // The event does not say whose turn begins: a subagent starting mid-turn does not restart the count.
174      await update($, view, (current): HudView => (current.isWorking ? current : { ...current, isWorking: true, turnStartedAt: now, toolsTurn: 0 })).catch(() => undefined)
175      if (config.animate || !config.hidden.has('turn')) startMoving($)
176    }
177    return next(e)
178  })
179
180  on('turn.step', async function* ($, e, next) {
181    // The request says how hard it asks the model to think; a subagent's own setting is not the session's.
182    if (e.agentId === undefined) {
183      const effort = e.effort === undefined ? null : String(e.effort)
184      await update($, view, (current): HudView => (current.effort === effort ? current : { ...current, effort })).catch(() => undefined)
185    }
186    // The response streams through untouched.
187    return yield* next(e)
188  })
189
190  on('tool.call', async ($, e, next) => {
191    if (await isShown($).catch(() => false)) {
192      await update($, view, (current): HudView => ({ ...current, toolsTurn: current.toolsTurn + 1, toolsSession: current.toolsSession + 1 })).catch(() => undefined)
193    }
194    return next(e)
195  })
196
197  on('turn.complete', async ($, e, next) => {
198    const done = await next(e)
199    if (e.agentId !== undefined) return done
200    stopMoving()
201    if (await isShown($).catch(() => false)) {
202      await update($, view, (current): HudView => ({ ...current, isWorking: false })).catch(() => undefined)
203      // Awaited: work left running when a hook returns is dropped with its dispatch.
204      await refresh($, true).catch(() => undefined)
205    }
206    return done
207  })
208
209  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
210    const beneath = await next(e)
211    if (config.placement !== 'below') return beneath
212    const current = await read($, view)
213    const lines = rows(current, config, await $.clock.now(), await read($, frame), e.viewport?.columns ?? DEFAULT_COLUMNS)
214    if (lines.length === 0) return beneath
215    const { Box, Text } = $.ui.resolve(e)
216
217    return (
218      <Box flexDirection="column">
219        {lines.map((line, at) => (
220          <Box key={`line-${at}`} flexDirection="row" columnGap={2}>
221            {line.map(segment => (
222              <Box key={segment.id} flexDirection="row" columnGap={1}>
223                <Text dimColor>{segment.label}</Text>
224                <Text color={segment.color} bold={segment.isBold} inverse={segment.isInverse}>
225                  {segment.text}
226                </Text>
227              </Box>
228            ))}
229          </Box>
230        ))}
231        {beneath}
232      </Box>
233    )
234  })
235
236  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
237    const beneath = await next(e)
238    if (config.placement !== 'above' || e.props.hasSurvey || e.props.view.agentId !== undefined) return beneath
239    const current = await read($, view)
240    const lines = rows(current, config, await $.clock.now(), await read($, frame), e.props.bodyColumns)
241    if (lines.length === 0) return beneath
242    const { Box, Text } = $.ui.resolve(e)
243
244    // No width here: the engine refuses its own band under a Box that sets one, and each line is cut to bodyColumns.
245    return (
246      <Box flexDirection="column">
247        {lines.map((line, at) => (
248          <Box key={`line-${at}`} flexDirection="row" columnGap={2}>
249            {line.map(segment => (
250              <Box key={segment.id} flexDirection="row" columnGap={1}>
251                <Text dimColor>{segment.label}</Text>
252                <Text color={segment.color} bold={segment.isBold} inverse={segment.isInverse}>
253                  {segment.text}
254                </Text>
255              </Box>
256            ))}
257          </Box>
258        ))}
259        {beneath}
260      </Box>
261    )
262  })
263}
264
hooks/hud.ts 232 lines
1import type { Git, HudView, Limit } from '../types'
2
3export const SEGMENTS = ['model', 'effort', 'context', 'limits', 'turn', 'tools', 'agents', 'git', 'worktree', 'cost', 'session', 'folder'] as const
4export type SegmentId = (typeof SEGMENTS)[number]
5
6export type Theme = 'neon' | 'ocean' | 'ember' | 'mono'
7export type Config = { theme: Theme; animate: boolean; placement: 'below' | 'above'; hidden: ReadonlySet<SegmentId> }
8
9/** One figure of the row: the label that names it, its text, and how the text is painted. */
10export type Segment = { id: string; kind: SegmentId; label: string; text: string; color: string | undefined; isBold: boolean; isInverse: boolean }
11
12type Level = 'ok' | 'warn' | 'danger'
13
14/** Left out first when the row is too wide; what is not named here goes last. */
15const DROP_ORDER: readonly SegmentId[] = ['folder', 'session', 'cost', 'worktree', 'tools', 'git', 'agents', 'effort', 'model', 'turn', 'limits', 'context']
16const GAP = 2
17const GAUGE_CELLS = 8
18const LEVEL_COLORS: Readonly<Record<Level, string>> = { ok: '#5fff87', warn: '#ffd75f', danger: '#ff5f5f' }
19const PALETTES: Readonly<Record<Theme, readonly string[]>> = {
20  neon: ['#ff5fd7', '#af87ff', '#5fafff', '#5fffd7', '#d7ff5f', '#ffaf5f'],
21  ocean: ['#5fd7ff', '#5fafff', '#5f87ff', '#87afff', '#5fffd7', '#87ffd7'],
22  ember: ['#ffd75f', '#ffaf5f', '#ff875f', '#ff5f5f', '#ff5f87', '#ffaf87'],
23  mono: [],
24}
25/** The worktree figure's label: the leafless tree, then its name. */
26const WORKTREE_LABEL = '\u{1FABE} worktree'
27const LIMIT_NAMES: Readonly<Record<string, string>> = { five_hour: '5h limit', seven_day: '7d limit', spend_limit: 'spend limit' }
28/** The share of a rate limit at which its figure turns yellow, and red. */
29const LIMIT_WARN_AT = 75
30const LIMIT_DANGER_AT = 90
31/** The figures of the first line; the rest go on the second. */
32const FIRST_LINE: ReadonlySet<SegmentId> = new Set(['model', 'effort', 'context', 'limits'])
33
34function isSegment(name: string): name is SegmentId {
35  return (SEGMENTS as readonly string[]).includes(name)
36}
37
38export function parseConfig(options: Readonly<Record<string, unknown>>): Config {
39  const theme = options.theme
40  const hide = typeof options.hide === 'string' ? options.hide : ''
41  return {
42    theme: theme === 'ocean' || theme === 'ember' || theme === 'mono' ? theme : 'neon',
43    animate: options.animate !== false,
44    placement: options.placement === 'above' ? 'above' : 'below',
45    hidden: new Set(hide.split(',').map(name => name.trim().toLowerCase()).filter(isSegment)),
46  }
47}
48
49/** `claude-fable-5-1[1m]` as `fable 5.1`: the family and its version, without the vendor, the date or the window. */
50export function shortModel(model: string): string {
51  const bare = model.replace(/\[.*\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
52  const [, family = bare, version = ''] = /^([a-z]+)-(\d+(?:-\d+)*)$/i.exec(bare) ?? []
53  return version === '' ? family : `${family} ${version.replace(/-/g, '.')}`
54}
55
56/** A length of time in its two largest parts: `42s`, `4m 12s`, `1h 6m`, `2d 3h`. */
57export function duration(ms: number): string {
58  const seconds = Math.max(0, Math.floor(ms / 1000))
59  if (seconds < 60) return `${seconds}s`
60  const minutes = Math.floor(seconds / 60)
61  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
62  const hours = Math.floor(minutes / 60)
63  return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
64}
65
66/** A length of time to the minute, for figures that move slowly: `under 1m`, `12m`, `1h 6m`. */
67export function coarse(ms: number): string {
68  const minutes = Math.floor(Math.max(0, ms) / 60_000)
69  if (minutes < 1) return 'under 1m'
70  if (minutes < 60) return `${minutes}m`
71  const hours = Math.floor(minutes / 60)
72  return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
73}
74
75export function gauge(percent: number, cells = GAUGE_CELLS): string {
76  const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
77  return '█'.repeat(filled) + '░'.repeat(cells - filled)
78}
79
80export function level(percent: number, warnAt: number, dangerAt: number): Level {
81  if (percent >= dangerAt) return 'danger'
82  return percent >= warnAt ? 'warn' : 'ok'
83}
84
85/** The last part of a path, whatever its slashes. */
86export function folderName(cwd: string): string {
87  const parts = cwd.split(/[\\/]+/).filter(part => part !== '')
88  return parts[parts.length - 1] ?? cwd
89}
90
91/** Reads `git status --porcelain=v2 --branch`: the branch, commits ahead and behind, and the count of changed files. */
92export function parseGit(output: string): Git | null {
93  const lines = output.split(/\r?\n/)
94  const head = lines.find(line => line.startsWith('# branch.head '))
95  if (head === undefined) return null
96  const [, ahead = '0', behind = '0'] = /^# branch\.ab \+(\d+) -(\d+)/.exec(lines.find(line => line.startsWith('# branch.ab ')) ?? '') ?? []
97  const changed = lines.filter(line => /^[12u?] /.test(line)).length
98  return { branch: head.slice('# branch.head '.length).trim(), changed, ahead: Number(ahead), behind: Number(behind) }
99}
100
101/** Reads `git rev-parse --show-toplevel`: the top folder of the worktree, the main checkout's or a linked one's. */
102export function parseWorktree(output: string): string | null {
103  const top = output.split(/\r?\n/)[0]?.trim() ?? ''
104  return top === '' ? null : top
105}
106
107function gitText(git: Git): string {
108  const marks = [git.changed > 0 ? `${git.changed} changed` : '', git.ahead > 0 ? `${git.ahead} ahead` : '', git.behind > 0 ? `${git.behind} behind` : '']
109  return [git.branch, ...marks.filter(mark => mark !== '')].join(' · ')
110}
111
112/**
113 * When a rate-limit window resets, in milliseconds since the epoch; NaN when the engine gave no time or one
114 * that cannot be read. A time without a zone is read as UTC, never as the machine's local time.
115 */
116export function resetTime(resetsAt: string | null): number {
117  if (resetsAt === null) return Number.NaN
118  return Date.parse(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(resetsAt) ? resetsAt : `${resetsAt}Z`)
119}
120
121function limitText(percent: number, resetsAt: string | null, now: number): string {
122  const at = resetTime(resetsAt)
123  const left = Number.isFinite(at) && at > now ? ` · resets in ${coarse(at - now)}` : ''
124  return `${Math.round(percent)}%${left}`
125}
126
127/**
128 * The limits to warn about now: each at the red level, unless already warned about in its window. `warned`
129 * holds, by limit, the reset time of the window it was last warned in (null for a limit with none). A limit
130 * is warned about again only in a new window: a reset time other than the warned one, once the warned one
131 * has passed. A limit with no reset time is warned about once.
132 */
133export function limitsToWarn(limits: readonly Limit[], warned: Readonly<Record<string, string | null>>, now: number): Limit[] {
134  return limits.filter(limit => {
135    if (level(limit.percent, LIMIT_WARN_AT, LIMIT_DANGER_AT) !== 'danger') return false
136    if (!Object.hasOwn(warned, limit.kind)) return true
137    const last = warned[limit.kind] ?? null
138    const lastReset = resetTime(last)
139    return limit.resetsAt !== last && Number.isFinite(lastReset) && now >= lastReset
140  })
141}
142
143/** What the toast for a limit at the red level says: `5h limit at 91% · resets in 2h 5m`. */
144export function limitWarning(limit: Limit, now: number): string {
145  return `${LIMIT_NAMES[limit.kind] ?? limit.kind} at ${limitText(limit.percent, limit.resetsAt, now)}`
146}
147
148type Figure = { kind: SegmentId; id: string; label: string; text: string; level?: Level }
149
150/** What each figure is called, what it says and at what level, in the order drawn; the empty ones left out. */
151function figures(view: HudView, now: number): Figure[] {
152  const out: Figure[] = []
153  if (view.model !== '') out.push({ kind: 'model', id: 'model', label: 'model', text: shortModel(view.model) })
154  if (view.effort !== null) out.push({ kind: 'effort', id: 'effort', label: 'effort', text: view.effort })
155  if (view.contextPercent !== null) {
156    const percent = Math.round(view.contextPercent)
157    out.push({ kind: 'context', id: 'context', label: 'context', text: `${gauge(percent)} ${percent}%`, level: level(percent, 70, 85) })
158  }
159  for (const limit of view.limits) {
160    out.push({
161      kind: 'limits',
162      id: `limit-${limit.kind}`,
163      label: LIMIT_NAMES[limit.kind] ?? limit.kind,
164      text: limitText(limit.percent, limit.resetsAt, now),
165      level: level(limit.percent, LIMIT_WARN_AT, LIMIT_DANGER_AT),
166    })
167  }
168  if (view.isWorking) out.push({ kind: 'turn', id: 'turn', label: 'turn', text: duration(now - view.turnStartedAt) })
169  if (view.toolsSession > 0) out.push({ kind: 'tools', id: 'tools', label: 'tools', text: `${view.toolsTurn} this turn · ${view.toolsSession} total` })
170  if (view.agents > 0) out.push({ kind: 'agents', id: 'agents', label: 'agents', text: `${view.agents} running` })
171  if (view.git !== null) out.push({ kind: 'git', id: 'git', label: 'git', text: gitText(view.git) })
172  if (view.worktree !== null) out.push({ kind: 'worktree', id: 'worktree', label: WORKTREE_LABEL, text: folderName(view.worktree) })
173  if (view.costUsd !== null) out.push({ kind: 'cost', id: 'cost', label: 'cost', text: `$${view.costUsd.toFixed(2)}` })
174  if (view.startedAt > 0) out.push({ kind: 'session', id: 'session', label: 'session', text: coarse(now - view.startedAt) })
175  if (view.folder !== '') out.push({ kind: 'folder', id: 'folder', label: 'folder', text: folderName(view.folder) })
176  return out
177}
178
179/** Cells a text takes on a terminal: an emoji from the pictograph planes two, a variation selector none, the rest one. */
180export function cells(text: string): number {
181  return Array.from(text).reduce((sum, char) => {
182    const code = char.codePointAt(0) ?? 0
183    return sum + (code === 0xfe0f ? 0 : code >= 0x1f000 ? 2 : 1)
184  }, 0)
185}
186
187/** A line's width as drawn: each figure's label, a space and its text, with the gap between figures. */
188export function rowWidth(segments: readonly { label: string; text: string }[]): number {
189  return segments.reduce((sum, segment) => sum + cells(segment.label) + 1 + cells(segment.text), 0) + GAP * Math.max(0, segments.length - 1)
190}
191
192/**
193 * The row as drawn at `frame`, in its lines: the figures that are not hidden, each line cut to fit `columns`.
194 * A figure with a level takes that level's colour; the rest take the theme's, which move along the row while a turn runs.
195 */
196export function rows(view: HudView, config: Config, now: number, frame: number, columns: number): Segment[][] {
197  const shown = figures(view, now).filter(figure => !config.hidden.has(figure.kind))
198  const lines = [shown.filter(figure => FIRST_LINE.has(figure.kind)), shown.filter(figure => !FIRST_LINE.has(figure.kind))].map(line => {
199    let kept = line
200    for (const kind of DROP_ORDER) {
201      if (rowWidth(kept) <= columns) break
202      kept = kept.filter(figure => figure.kind !== kind)
203    }
204    return kept
205  })
206  const count = lines.reduce((sum, line) => sum + line.length, 0)
207  const palette = PALETTES[config.theme]
208  const isMoving = config.animate && view.isWorking
209  const shift = isMoving ? Math.floor(frame / 2) : 0
210  let index = 0
211  return lines
212    .filter(line => line.length > 0)
213    .map(line =>
214      line.map(figure => {
215        const at = index
216        index += 1
217        const isLit = isMoving && (at + shift) % Math.max(3, count) === 0
218        const themed = palette.length === 0 ? undefined : palette[(at + shift) % palette.length]
219        const isAlarm = figure.level === 'danger'
220        return {
221          id: figure.id,
222          kind: figure.kind,
223          label: figure.label,
224          text: figure.text,
225          color: figure.level === undefined ? themed : LEVEL_COLORS[figure.level],
226          isBold: isAlarm || isLit,
227          isInverse: isAlarm && isMoving && frame % 4 < 2,
228        }
229      }),
230    )
231}
232
types/index.d.ts 37 lines
1/** One rate-limit window as the engine reports it. */
2export type Limit = { kind: string; percent: number; resetsAt: string | null }
3
4/** The working tree's state, from one `git status`. */
5export type Git = { branch: string; changed: number; ahead: number; behind: number }
6
7/** The figures the row shows, as last read. */
8export type HudView = {
9  model: string
10  /** How hard the last request asked the model to think; null until a request has gone out, or for a model that takes none. */
11  effort: string | null
12  /** The context window's fill, 0-100; null until the engine has a reading. */
13  contextPercent: number | null
14  limits: Limit[]
15  costUsd: number | null
16  /** When the session began; 0 until first read. */
17  startedAt: number
18  /** Null outside a git repository. */
19  git: Git | null
20  /** The top folder of the worktree the session is in, the main checkout's or a linked one's; null outside a git repository. */
21  worktree: string | null
22  folder: string
23  /** Subagents running now. */
24  agents: number
25  toolsTurn: number
26  toolsSession: number
27  isWorking: boolean
28  turnStartedAt: number
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    /** `warned`: by limit, the reset time of the window its red level was last toasted in (null when it had none). */
34    hud: { view: HudView; frame: number; warned: Record<string, string | null> }
35  }
36}
37