SLOPSHOPPER

live-spinner

Spinner says what is actually running: the Bash command, the file being read or edited, the search, the subagent

newspinnerguardprocesstimer
v0.4.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/live-spinner
A shopper browsing a rack in a slop shop
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane
live-spinnerSpinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet
pr-watchTracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs
house-styleNo em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one
handoff/carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop
agent-jobs/jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes
zsh-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result
agent-modelsPins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on
worktrees/worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once
pr-rulesBlocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules onoffauto` overrides per repo (kept across sessions)

Install

claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods

Or load from a checkout without installing:

claude --plugin-dir ./context-watch --plugin-dir ./live-spinner

Check

claude plugin validate ./context-watch
claude plugin test ./context-watch

tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.

Source 2 files
hooks/register.ts 196 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Running } from '../types'
5
6const running = atom({ plugin: 'live-spinner', key: 'running' } as const, [] as Running[])
7const tick = atom({ plugin: 'live-spinner', key: 'tick' } as const, 0)
8
9// A tool's own elapsed time joins its label once it has run this long.
10const SHOW_ELAPSED_MS = 15_000
11// A signed commit or tag this quiet is usually waiting on the signer's approval prompt.
12const SIGNING_HINT_MS = 5_000
13
14const SIGNED_GIT = /\bgit\s+(?:-C\s+\S+\s+)?(commit|tag|merge|rebase|cherry-pick|revert|am)\b/
15
16// Room the engine keeps on the spinner line for the glyph, elapsed time and tokens.
17const RESERVED = 34
18const MIN_WIDTH = 20
19
20const firstLine = (text: string) => (text.split('\n')[0] ?? '').trim()
21
22const base = (path: string) => path.split('/').filter(Boolean).at(-1) ?? path
23
24const field = (input: object, key: string) => {
25  const value = (input as Record<string, unknown>)[key]
26
27  return typeof value === 'string' && value.trim() !== '' ? value : undefined
28}
29
30export const clip = (text: string, width: number) =>
31  text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
32
33// Drops what says little about the work: leading cd's and env assignments,
34// the parent folders of absolute paths, and everything after the first pipe.
35export const shortenCommand = (command: string) => {
36  let text = firstLine(command)
37  text = text.replace(/^(\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*)+/, '')
38  text = text.replace(/^(\s*[A-Z_][A-Z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+)+/, '')
39  text = text.replace(/(^|[\s='"])(?:~|\/[^\s'"]*)\/([^\s/'"]+)/g, '$1$2')
40
41  const [head, ...rest] = text.split(/\s\|\s/)
42
43  return `${(head ?? '').trim()}${rest.length > 0 ? ' | …' : ''}`
44}
45
46// `ssh [options] host command`: the host and what it runs there.
47export const remoteOf = (command: string): { host: string; remote: string } | null => {
48  const match = command.match(/^ssh\s+((?:-[a-zA-Z]+(?:\s+(?!-)\S+)?\s+)*)([^\s-]\S*)\s*(.*)$/)
49  if (match === null) {
50    return null
51  }
52
53  const host = (match[2] ?? '').replace(/^.*@/, '')
54  const remote = (match[3] ?? '').trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
55
56  return { host, remote: remote === '' ? 'shell' : remote }
57}
58
59export const formatElapsed = (ms: number) => {
60  const seconds = Math.floor(ms / 1000)
61
62  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
63}
64
65export const labelFor = (tool: string, input: object): Pick<Running, 'label' | 'detail'> => {
66  const path = field(input, 'file_path') ?? field(input, 'notebook_path')
67
68  switch (tool) {
69    case 'Bash': {
70      const command = shortenCommand(field(input, 'command') ?? '')
71      const description = field(input, 'description')
72      const ssh = remoteOf(command)
73
74      if (description !== undefined) {
75        const label = firstLine(description)
76        if (ssh === null) {
77          return { label, detail: command }
78        }
79
80        return label.includes(ssh.host) ? { label } : { label, detail: `on ${ssh.host}` }
81      }
82
83      return ssh !== null ? { label: `${ssh.host} › ${ssh.remote}` } : { label: `$ ${command}` }
84    }
85    case 'Read':
86      return { label: `Reading ${base(path ?? '')}` }
87    case 'Edit':
88    case 'Write':
89    case 'NotebookEdit':
90      return { label: `Editing ${base(path ?? '')}` }
91    case 'Grep':
92      return { label: `Searching "${firstLine(field(input, 'pattern') ?? '')}"` }
93    case 'Glob':
94      return { label: `Finding ${firstLine(field(input, 'pattern') ?? '')}` }
95    case 'WebFetch': {
96      const url = field(input, 'url') ?? ''
97
98      return { label: `Fetching ${url.match(/^https?:\/\/([^/]+)/)?.[1] ?? url}` }
99    }
100    case 'WebSearch':
101      return { label: `Searching web: ${firstLine(field(input, 'query') ?? '')}` }
102    case 'Agent':
103    case 'Task':
104      return { label: `Agent: ${firstLine(field(input, 'description') ?? 'working')}` }
105  }
106
107  const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
108  if (mcp !== null) {
109    return { label: `${(mcp[1] ?? '').replace(/^claude_ai_|^plugin_/, '')}: ${mcp[2] ?? ''}` }
110  }
111
112  return { label: tool }
113}
114
115// The label always shows; the elapsed time and the detail join it, in that
116// order, only when they fit whole.
117export const spinnerText = (call: Pick<Running, 'label' | 'detail'>, more: number, width: number, elapsedMs = 0) => {
118  const time = elapsedMs >= SHOW_ELAPSED_MS ? ` · ${formatElapsed(elapsedMs)}` : ''
119  const suffix = `${time}${more > 0 ? ` (+${more})` : ''}`
120  const room = Math.max(MIN_WIDTH, width - suffix.length)
121  const full = call.detail !== undefined ? `${call.label} · ${call.detail}` : call.label
122  const text = full.length <= room ? full : clip(call.label, room)
123
124  return `${text}${suffix}`
125}
126
127// The git subcommand when `command` runs one that may sign, else null.
128export const signingCommand = (command: string) => command.match(SIGNED_GIT)?.[1] ?? null
129
130const willSign = async ($: EngineInterface, command: string) => {
131  const sub = signingCommand(command)
132  if (sub === null) return false
133  if (/\s-S\b|--gpg-sign\b|\btag\s+(-\w*s|--sign)\b/.test(command)) return true
134
135  const key = sub === 'tag' ? 'tag.gpgsign' : 'commit.gpgsign'
136  const dir = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*(&&|;)/)?.[1]?.replace(/^["']|["']$/g, '')
137  const config = await $.process.run(['git', 'config', '--get', key], dir !== undefined && !dir.startsWith('~') ? { cwd: dir } : {}).catch(() => undefined)
138
139  return config?.stdout.trim() === 'true'
140}
141
142export const register: Register = on => {
143  // Redraws the spinner each second while a tool has run long enough to show its time.
144  on('session.start', async ($, e, next) => {
145    $.clock.every(1000, async () => {
146      const list = await read($, running)
147      const now = await $.clock.now()
148      const isDue = (call: Running) => now - call.startedAt >= (call.isSigning === true ? SIGNING_HINT_MS : SHOW_ELAPSED_MS)
149      if (list.some(isDue)) {
150        await update($, tick, n => n + 1)
151      }
152    })
153
154    return next(e)
155  })
156
157  on('tool.call', async ($, e, next) => {
158    const isSigning = e.tool === 'Bash' && (await willSign($, e.command).catch(() => false))
159    const call: Running = { id: e.tool_use_id, ...labelFor(e.tool, e), startedAt: await $.clock.now(), isSigning }
160    await update($, running, list => [...list, call])
161
162    try {
163      return await next(e)
164    } finally {
165      await update($, running, list => list.filter(one => one.id !== call.id))
166    }
167  })
168
169  on('turn.complete', async ($, e, next) => {
170    if (e.agentId === undefined) {
171      await update($, running, () => [])
172    }
173
174    return next(e)
175  })
176
177  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
178    await read($, tick)
179    const list = await read($, running)
180    const latest = list.at(-1)
181
182    if (latest === undefined || e.props.message !== null) {
183      return next(e)
184    }
185
186    const width = (e.viewport?.columns ?? 80) - RESERVED - e.props.word.length
187    const elapsed = (await $.clock.now()) - latest.startedAt
188    const shown = latest.isSigning === true && elapsed >= SIGNING_HINT_MS
189      ? { label: 'Waiting for signing approval', detail: latest.label }
190      : latest
191    const message = spinnerText(shown, list.length - 1, width, elapsed)
192
193    return next({ ...e, props: { ...e.props, message } })
194  })
195}
196
types/index.d.ts 8 lines
1export type Running = { id: string; label: string; detail?: string; startedAt: number; isSigning?: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'live-spinner': { running: Running[]; tick: number }
6  }
7}
8