Save selected chat text (or typed text) with /note to a notes list shared across projects, shown in a side pane with done checkboxes.

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 195 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Note } from '../types'
5import { clean, compose, preview } from './compose'
6
7// The list lives in $.store so every session and project shares it; the
8// atom mirrors it for drawing.
9const STORE_KEY = 'notes'
10const PANE = 'notes'
11const PANE_TITLE = 'Notes'
12
13const notes = atom({ plugin: 'notes', key: 'notes' } as const, [])
14const showDone = atom({ plugin: 'notes', key: 'showDone' } as const, false)
15const sessionId = atom({ plugin: 'notes', key: 'sessionId' } as const, '')
16const expanded = atom({ plugin: 'notes', key: 'expanded' } as const, [])
17
18const pad = (n: number) => String(n).padStart(2, '0')
19const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
20const formatWhen = (ms: number) => {
21 const d = new Date(ms)
22 return `${MONTHS[d.getMonth()]} ${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
23}
24const folderName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
25
26const asNotes = (value: unknown): Note[] => (Array.isArray(value) ? (value as Note[]) : [])
27
28// Re-reads the store before every change, so two sessions writing in turn
29// both keep their notes.
30async function change($: EngineInterface, fn: (list: Note[]) => Note[]) {
31 const next = fn(asNotes(await $.store.get(STORE_KEY)))
32 await $.store.set(STORE_KEY, next)
33 await update($, notes, () => next)
34 return next
35}
36
37async function reload($: EngineInterface) {
38 const list = asNotes(await $.store.get(STORE_KEY))
39 await update($, notes, () => list)
40}
41
42async function openPane($: EngineInterface) {
43 await reload($)
44 await $.ui.open({ id: PANE, title: PANE_TITLE })
45}
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 await $.command.register({
50 name: 'note',
51 description: 'Save the selected text (plus an optional comment) to Notes',
52 argumentHint: '[comment, or the text to save]',
53 immediate: true,
54 })
55 await $.command.register({ name: 'notes', description: 'Open the Notes pane', immediate: true })
56 const id = await $.session.id()
57 await update($, sessionId, () => id)
58 await reload($)
59
60 return next(e)
61 })
62
63 on('command.run', { command: 'notes' }, async $ => {
64 await openPane($)
65
66 return { text: 'Notes pane opened.' }
67 })
68
69 on('command.run', { command: 'note' }, async ($, e) => {
70 // Not every surface reads a selection: then /note takes typed text only.
71 let selection: { text: string; requestId?: string } | undefined
72 try {
73 selection = await $.ui.selection()
74 } catch {
75 selection = undefined
76 }
77 const composed = compose(selection, e.args)
78 if (composed === undefined) {
79 return { text: 'Nothing to save: select text in the chat first, or type /note <text>.' }
80 }
81
82 const now = await $.clock.now()
83 const note: Note = {
84 ...composed,
85 id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
86 createdAt: now,
87 cwd: await $.session.cwd(),
88 sessionId: await $.session.id(),
89 done: false,
90 }
91 await change($, list => [note, ...list])
92 await openPane($)
93
94 const firstLine = note.text.split('\n')[0] ?? ''
95 return { text: `Noted: ${firstLine.length > 60 ? `${firstLine.slice(0, 60)}…` : firstLine}` }
96 })
97
98 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
100 const list = await read($, notes)
101 const isShowingDone = await read($, showDone)
102 const current = await read($, sessionId)
103 const expandedIds = await read($, expanded)
104 const open = list.filter(note => !note.done)
105 const done = list.filter(note => note.done)
106 const shown = isShowingDone ? [...open, ...done] : open
107
108 return (
109 <Box flexDirection="column" gap={1}>
110 <Box flexDirection="row" gap={1}>
111 <Text color="claude" bold>{open.length} open</Text>
112 <Text dimColor>· {done.length} done</Text>
113 {done.length > 0 ? (
114 <Button
115 key="toggle-done"
116 label={isShowingDone ? 'Hide done' : 'Show done'}
117 onPress={() => update($, showDone, value => !value)}
118 />
119 ) : null}
120 {done.length > 0 ? (
121 <Button
122 key="clear-done"
123 label="Clear done"
124 onPress={() => change($, all => all.filter(note => !note.done))}
125 />
126 ) : null}
127 </Box>
128 {list.length === 0 ? (
129 <Text dimColor>No notes yet. Select text in the chat and type /note.</Text>
130 ) : null}
131 {open.length === 0 && done.length > 0 && !isShowingDone ? (
132 <Text dimColor>All done.</Text>
133 ) : null}
134 {shown.map(note => (
135 <Box key={note.id} flexDirection="column">
136 <Box flexDirection="row" gap={1}>
137 <Button
138 key={`done-${note.id}`}
139 label={note.done ? '☑' : '☐'}
140 onPress={async () => {
141 const at = await $.clock.now()
142 await change($, all =>
143 all.map(one =>
144 one.id === note.id
145 ? { ...one, done: !one.done, doneAt: one.done ? undefined : at }
146 : one,
147 ),
148 )
149 }}
150 />
151 <Text dimColor>{formatWhen(note.createdAt)}</Text>
152 <Text color="suggestion">{folderName(note.cwd)}</Text>
153 {note.sessionId === current && note.requestId !== undefined ? (
154 <Button
155 key={`jump-${note.id}`}
156 label="Jump"
157 onPress={() => $.ui.scroll({ to: { requestId: note.requestId! }, block: 'center' })}
158 />
159 ) : null}
160 <Button
161 key={`delete-${note.id}`}
162 label="Delete"
163 onPress={() => change($, all => all.filter(one => one.id !== note.id))}
164 />
165 </Box>
166 {(() => {
167 const isOpen = expandedIds.includes(note.id)
168 const cut = preview(clean(note.text))
169 return (
170 <Box flexDirection="column">
171 <Markdown text={isOpen ? clean(note.text) : cut.text} dimColor={note.done} />
172 {cut.isCut ? (
173 <Button
174 key={`more-${note.id}`}
175 label={isOpen ? 'Less' : 'More'}
176 onPress={() =>
177 update($, expanded, ids =>
178 ids.includes(note.id) ? ids.filter(id => id !== note.id) : [...ids, note.id],
179 )
180 }
181 />
182 ) : null}
183 </Box>
184 )
185 })()}
186 {note.comment !== undefined ? (
187 <Text italic dimColor={note.done}>↳ {note.comment}</Text>
188 ) : null}
189 </Box>
190 ))}
191 </Box>
192 )
193 })
194}
195hooks/compose.ts 36 lines1// What /note saves: the selection when there is one, the typed text riding
2// along as its comment; otherwise the typed text itself.
3export type Composed = { text: string; comment?: string; requestId?: string }
4
5const PREVIEW_LINES = 6
6const PREVIEW_CHARS = 400
7
8// Pasting into the prompt can wrap the text in a <pasted_content> tag pair.
9export const clean = (text: string) =>
10 text
11 .replace(/<pasted_content\b[^>]*>\r?\n?/g, '')
12 .replace(/\r?\n?<\/pasted_content\b[^>]*>/g, '')
13 .trim()
14
15export const compose = (
16 selection: { text: string; requestId?: string } | undefined,
17 typed: string,
18): Composed | undefined => {
19 const selected = clean(selection?.text ?? '')
20 const args = clean(typed)
21 if (selected !== '') {
22 return { text: selected, comment: args !== '' ? args : undefined, requestId: selection?.requestId }
23 }
24 return args !== '' ? { text: args } : undefined
25}
26
27// The first lines of a long note, closing a code fence the cut left open.
28export const preview = (text: string): { text: string; isCut: boolean } => {
29 const lines = text.split('\n')
30 let shown = lines.slice(0, PREVIEW_LINES).join('\n')
31 if (shown.length > PREVIEW_CHARS) shown = `${shown.slice(0, PREVIEW_CHARS)}…`
32 if (shown === text) return { text, isCut: false }
33 const fences = (shown.match(/^\s*```/gm) ?? []).length
34 return { text: fences % 2 === 1 ? `${shown}\n\`\`\`` : shown, isCut: true }
35}
36types/index.d.ts 19 lines1export type Note = {
2 id: string
3 text: string
4 comment?: string
5 createdAt: number
6 // Where it was taken: the session's folder and id, and the transcript row.
7 cwd: string
8 sessionId: string
9 requestId?: string
10 done: boolean
11 doneAt?: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 notes: { notes: Note[]; showDone: boolean; sessionId: string; expanded: string[] }
17 }
18}
19