SLOPSHOPPER

turn-pulse

A band above the prompt summarizing the last turn: duration, files edited (edits outside the working folder flagged), tool results, and the turn's share of the…

newbandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-pulse
› 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 › /pulse ⎿ turn-pulse: Turn pulse band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ turn-pulse: session: 5h +0%
README

claude-code-mods

Two small mods for Claude Code, built on its function-hooks plugin API.

ModWhat it does
turn-pulseA band above the prompt summarizing the last turn, plus a live status line while Claude works.
notes/note saves chat text to a notes list shared across projects; /notes opens it in a side pane.

Early access. The mod API these use is marked EARLY ACCESS and may change between Claude Code releases. Built and tested on Claude Code 2.1.293, on Windows, in the desktop app's Code tab.

Install

In a terminal Claude Code session, type one line per mod:

/plugin install turn-pulse --marketplace ilya-paskhover/claude-code-mods
/plugin install notes --marketplace ilya-paskhover/claude-code-mods

Answer y to add the marketplace (asked only the first time), then pick a scope. The user scope makes the mod load in every session, including sessions the desktop app starts. /plugin is not available inside the desktop app's Code tab, so install from a terminal.

turn-pulse

turn-pulse: the last-turn band above the prompt and the session total in the status line

A band labeled ◆ last turn sits above the prompt after each turn:

  • Duration of the turn, in yellow past 2 minutes.
  • Files edited, in green. An edit outside the session's working folder shows in bold red, with a 15 second toast.
  • Tool results: ✓ N ok, and ✗ N failed (Tool ×n) when something failed.
  • Usage-limit cost of the turn, for example 5h +1% · week +0.3%.

The status line shows ▶ Tool · N ok · ✗ N failed live during a turn, and session: 5h +6% · week +2% between turns. A toast appears when a turn takes 60 seconds or more. /pulse hides or shows the band.

Limits:

  • Usage numbers are account-wide, so other sessions running at the same time count toward them.
  • Tracking starts when the mod loads, and the handling of a limit window resetting is heuristic.
  • Paths are compared case-insensitively. That is right on Windows but could misjudge an "outside" edit on a case-sensitive Linux file system.

notes

notes: the Notes side pane with an open note

  • /note <text> saves the typed text. With text selected in the transcript, /note <comment> saves the selection with the typed text as its comment.
  • Notes are kept in the mod's own store, shared across all sessions and projects.
  • /notes opens a Notes side pane: notes render as Markdown, long ones fold behind More/Less, and each has a ☐/☑ done toggle, Delete, and Jump (scroll to the message it came from; same session only). Show/Hide done and Clear done act on the whole list.
  • Text pasted into the prompt has its <pasted_content> wrapper stripped.

Limits:

  • In the desktop app the selection is not passed to mods, so /note there needs pasted or typed text, and Jump never appears. Selection is documented to work in the fullscreen terminal; I have not verified that. Tracked in #1.

Developing

Each mod is a plugin folder under plugins/. To run a working copy instead of the installed one, name the folders in the env block of ~/.claude/settings.json (separated by ; on Windows, : elsewhere) and uninstall the marketplace copy so the mod does not load twice:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/plugins/turn-pulse;/path/to/claude-code-mods/plugins/notes"
  }
}

Interactive sessions watch those folders and reload a mod when its files are saved. Checks, per mod folder:

claude plugin validate plugins/notes
claude plugin test plugins/notes

Each mod's tsconfig.json extends the type declarations the engine writes into .claude-plugin/types/ when it loads the mod (gitignored), so tsc -p plugins/notes works once the mod has loaded once.

License

MIT

Source 2 files
hooks/register.tsx 206 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { EditedFile, Pulse, WindowTrack } from '../types'
5
6const last = atom({ plugin: 'turn-pulse', key: 'last' } as const, null)
7const isHidden = atom({ plugin: 'turn-pulse', key: 'isHidden' } as const, false)
8const windows = atom({ plugin: 'turn-pulse', key: 'windows' } as const, {})
9
10const EDITING = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
11const SLOW_SECONDS = 120
12const TOAST_SECONDS = 60
13const WINDOW_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
14
15const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
16
17// Windows paths compare case-insensitively and with either slash.
18const normalize = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
19const isAbsolute = (path: string) => /^([a-z]:)?[\\/]/i.test(path)
20const isOutsideOf = (cwd: string, path: string) => {
21  if (!isAbsolute(path) || cwd === '') return false
22  const root = normalize(cwd)
23  const target = normalize(path)
24  return target !== root && !target.startsWith(`${root}/`)
25}
26
27const formatTime = (seconds: number) =>
28  seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
29
30const round = (n: number) => Math.round(n * 10) / 10
31const label = (kind: string) => WINDOW_LABELS[kind] ?? kind
32const formatSpent = (spent: Record<string, number>) =>
33  Object.entries(spent)
34    .map(([kind, n]) => `${label(kind)} +${round(n)}%`)
35    .join(' · ')
36
37// The limits are account-wide: the session's share is how far each window
38// moved since the session first saw it, carried across window resets.
39const track = (tracks: Record<string, WindowTrack>, limits: SessionRateLimit[]) => {
40  const next = { ...tracks }
41  for (const limit of limits) {
42    const now = limit.percentUsed
43    const seen = next[limit.kind]
44    if (seen === undefined) {
45      next[limit.kind] = { start: now, last: now, carried: 0, resetsAt: limit.resetsAt }
46      continue
47    }
48    const hasReset =
49      (limit.resetsAt !== undefined && seen.resetsAt !== undefined && limit.resetsAt !== seen.resetsAt) ||
50      now < seen.last - 0.5
51    next[limit.kind] = hasReset
52      ? { start: 0, last: now, carried: seen.carried + (seen.last - seen.start), resetsAt: limit.resetsAt }
53      : { ...seen, last: now, resetsAt: limit.resetsAt ?? seen.resetsAt }
54  }
55  return next
56}
57
58const sessionSpent = (tracks: Record<string, WindowTrack>) =>
59  Object.fromEntries(
60    Object.entries(tracks).map(([kind, t]) => [kind, Math.max(0, t.carried + t.last - t.start)]),
61  )
62
63const countFails = (failed: Record<string, number>) => Object.values(failed).reduce((a, b) => a + b, 0)
64
65function liveStatus($: EngineInterface, tool: string, ok: number, fails: number) {
66  $.ui.status(`▶ ${tool} · ${ok} ok${fails > 0 ? ` · ✗ ${fails} failed` : ''}`)
67}
68
69// Reads the windows, folds them into the session's tracks, returns its spend.
70async function measure($: EngineInterface, limits?: SessionRateLimit[]) {
71  const reading = limits ?? (await $.session.usage()).rateLimits
72  const tracks = await update($, windows, tracks => track(tracks, reading))
73  return sessionSpent(tracks)
74}
75
76function idleStatus($: EngineInterface, spent: Record<string, number>) {
77  $.ui.status(Object.keys(spent).length > 0 ? `session: ${formatSpent(spent)}` : undefined)
78}
79
80export const register: Register = on => {
81  let cwd = ''
82  let startedAt = 0
83  let inTurn = false
84  let ok = 0
85  let files = new Map<string, EditedFile>()
86  let failed: Record<string, number> = {}
87  let spentAtStart: Record<string, number> = {}
88
89  on('prompt.submit', async ($, e, next) => {
90    startedAt = await $.clock.now()
91    cwd = await $.session.cwd()
92    inTurn = true
93    ok = 0
94    files = new Map()
95    failed = {}
96    spentAtStart = await measure($)
97
98    return next(e)
99  })
100
101  on('tool.call', async ($, e, next) => {
102    liveStatus($, e.tool, ok, countFails(failed))
103
104    const ran = await next(e)
105    if (ran.deny === undefined && ran.isError === true) {
106      failed[e.tool] = (failed[e.tool] ?? 0) + 1
107    } else if (ran.deny === undefined) {
108      ok += 1
109      const path = (e as { file_path?: unknown }).file_path
110      if (EDITING.has(e.tool) && typeof path === 'string') {
111        files.set(normalize(path), { name: baseName(path), path, isOutside: isOutsideOf(cwd, path) })
112      }
113    }
114    liveStatus($, e.tool, ok, countFails(failed))
115
116    return ran
117  })
118
119  on('turn.complete', async ($, e, next) => {
120    inTurn = false
121    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
122    const spentNow = await measure($)
123    const spent = Object.fromEntries(
124      Object.entries(spentNow)
125        .map(([kind, n]): [string, number] => [kind, n - (spentAtStart[kind] ?? n)])
126        .filter(([, n]) => n >= 0.05),
127    )
128    const pulse: Pulse = { seconds, ok, files: [...files.values()], failed: { ...failed }, spent }
129    await update($, last, () => pulse)
130    idleStatus($, spentNow)
131
132    const outside = pulse.files.filter(file => file.isOutside)
133    const fails = countFails(failed)
134    if (outside.length > 0) {
135      $.ui.toast(`⚠ Edited outside ${cwd}: ${outside.map(file => file.path).join(', ')}`, { timeoutMs: 15000 })
136    } else if (seconds >= TOAST_SECONDS) {
137      const parts = [`Done in ${formatTime(seconds)}`, `${pulse.files.length} files edited`]
138      if (fails > 0) parts.push(`${fails} failed`)
139      $.ui.toast(parts.join(' · '), { timeoutMs: 10000 })
140    }
141
142    return next(e)
143  })
144
145  // Pushed when a window moves a whole point, also between turns.
146  on('session.measure', async ($, e, next) => {
147    const spent = await measure($, e.rateLimits)
148    if (!inTurn) idleStatus($, spent)
149
150    return next(e)
151  })
152
153  on('command.run', { command: 'pulse' }, async ($, e, next) => {
154    const hidden = await read($, isHidden)
155    await update($, isHidden, () => !hidden)
156
157    return { text: hidden ? 'Turn pulse band shown.' : 'Turn pulse band hidden.' }
158  })
159
160  on('session.start', async ($, e, next) => {
161    await $.command.register({ name: 'pulse', description: 'Show or hide the turn pulse band' })
162    idleStatus($, await measure($))
163
164    return next(e)
165  })
166
167  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168    const pulse = await read($, last)
169    // A pulse saved by an older version of this mod has another shape: skip it.
170    const isCurrent = pulse !== null && typeof pulse.ok === 'number'
171    if (e.props.hasSurvey || !isCurrent || (await read($, isHidden))) {
172      return next(e)
173    }
174
175    const { Box, Button, Text } = $.ui.resolve(e)
176    const outside = pulse.files.filter(file => file.isOutside)
177    const inside = pulse.files.filter(file => !file.isOutside)
178    const shown = inside.slice(0, 3).map(file => file.name).join(', ')
179    const more = inside.length > 3 ? ` +${inside.length - 3}` : ''
180    const failures = Object.entries(pulse.failed)
181      .map(([tool, n]) => (n > 1 ? `${tool} ×${n}` : tool))
182      .join(', ')
183    const fails = countFails(pulse.failed)
184    const spent = pulse.spent ?? {}
185
186    return (
187      <Box flexDirection="row" gap={1}>
188        <Text color="claude" bold>◆ last turn</Text>
189        <Text color={pulse.seconds > SLOW_SECONDS ? 'warning' : 'success'}>⏱ {formatTime(pulse.seconds)}</Text>
190        <Text dimColor>│</Text>
191        {outside.length > 0 ? (
192          <Text color="error" bold>⚠ outside: {outside.map(file => file.name).join(', ')}</Text>
193        ) : null}
194        {inside.length > 0 ? <Text color="diffAdded">✎ {shown}{more}</Text> : null}
195        {pulse.files.length === 0 ? <Text dimColor>no edits</Text> : null}
196        <Text dimColor>│</Text>
197        <Text color="success">✓ {pulse.ok} ok</Text>
198        {fails > 0 ? <Text color="error">✗ {fails} failed ({failures})</Text> : null}
199        {Object.keys(spent).length > 0 ? <Text dimColor>│</Text> : null}
200        {Object.keys(spent).length > 0 ? <Text color="suggestion">{formatSpent(spent)}</Text> : null}
201        <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
202      </Box>
203    )
204  })
205}
206
types/index.d.ts 29 lines
1export type EditedFile = { name: string; path: string; isOutside: boolean }
2
3export type Pulse = {
4  seconds: number
5  ok: number
6  files: EditedFile[]
7  failed: Record<string, number>
8  // Points of each rate-limit window this turn used, by window kind.
9  spent?: Record<string, number>
10}
11
12// One rate-limit window as this session has watched it.
13export type WindowTrack = {
14  start: number
15  last: number
16  carried: number
17  resetsAt?: string
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'turn-pulse': {
23      last: Pulse | null
24      isHidden: boolean
25      windows: Record<string, WindowTrack>
26    }
27  }
28}
29