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…

Two small mods for Claude Code, built on its function-hooks plugin API.
| Mod | What it does |
|---|---|
| turn-pulse | A 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.
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.

A band labeled ◆ last turn sits above the prompt after each turn:
✓ N ok, and ✗ N failed (Tool ×n) when something failed.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:

/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 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.<pasted_content> wrapper stripped.Limits:
/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.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.
hooks/register.tsx 206 lines1import { 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}
206types/index.d.ts 29 lines1export 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