SLOPSHOPPER

notes

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notes
│ ┃ Notes ✕ › fix the failing auth test and add an audit log call │ ┃ 0 open · 0 done │ ┃ ⏺ Read(src/auth.ts) │ ┃ No notes yet. Select text in the chat and ⎿ Read 6 lines │ ┃ type /note. ⏺ 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 │ │ › /note │ ⎿ notes: Nothing to save: select text in the chat first, or type / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notes
0 open · 0 done No notes yet. Select text in the chat and type /note.
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 3 files
hooks/register.tsx 195 lines
1import { 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}
195
hooks/compose.ts 36 lines
1// 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}
36
types/index.d.ts 19 lines
1export 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