A notepad pane for Claude Code: per-project and global notes, tags, search, a multi-line editor, NOTES.md sync, pinned notes sent as context, and tools so…

A notepad pane for Claude Code, as a mod.
<!-- demo video: drag an .mp4 into a GitHub issue or the README editor and paste the user-attachments link here -->
Ideas, follow-ups and TODOs pile up while Claude works, and the transcript is a bad place to keep them. claudepad adds a notes pane inside the same TUI, in the terminal or the desktop Code tab. Notes belong to the project you are in or live globally, and Claude can read them, add to them and keep your pinned ones in mind. Nothing leaves your machine: notes sit in the mod's own store under your Claude Code config folder.
/notes opens or closes it. /note <text> adds a quick note, /note -g <text> a global one, and a bare /note opens the multi-line editor.#tag matches a tag exactly. The same search is /notes find <text>.#hashtags in a note. The pane colors them and adds a clickable tag bar; /notes tags lists them.Ctrl+S to save, with a line-by-line fallback where the full one cannot draw.NOTES.md sync. /notes export [--force] writes the project's notes to a NOTES.md in the folder, /notes import [--replace] reads it back. Export never overwrites a NOTES.md it did not write unless forced.📝 3 here · 5 global.add_note and list_notes tools, so it can park a follow-up instead of derailing the task, and look up what you wrote.claude plugin marketplace add nwilsson/claudepad
claude plugin install claudepad@claudepad
Restart Claude Code, then run /notes.
To try it from a clone without installing:
claude --plugin-dir /path/to/claudepad
Use a Claude Code at least as new as 2.1.286, the version the mod was built against. Older CLIs reject the hook API.
.claude-plugin/plugin.json: the manifest; marketplace.json lists it.hooks/register.tsx: commands, pane, tools, prompt context, export and import.hooks/editor.tsx: the multi-line editor component.types/index.d.ts: the mod's state types.tests/editor.test.tsx: the editor test.claude plugin validate .
npx -p typescript tsc -p . # after the engine has laid .claude-plugin/types
claude plugin test . # tests/*.test.tsx, run in the engine's own sandbox
scripts/check.sh runs all three and checks that both manifests carry the same version. CI runs it on every push and pull request, without tsc: the types only exist once the engine has loaded the plugin.
MIT
hooks/register.tsx 892 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EditorSession, Note, Scope } from '../types'
5
6const PANE = 'claudepad'
7const STORE_KEY = 'notes'
8const ADD_TOOL = 'mcp__claudepad__add_note'
9const LIST_TOOL = 'mcp__claudepad__list_notes'
10
11const notes = atom({ plugin: 'claudepad', key: 'notes' } as const, [] as Note[])
12const editor = atom({ plugin: 'claudepad', key: 'editor' } as const, null as EditorSession | null)
13const draft = atom({ plugin: 'claudepad', key: 'draft' } as const, '')
14const isLineMode = atom({ plugin: 'claudepad', key: 'isLineMode' } as const, false)
15const EDITOR_MODULE = './editor.tsx'
16
17/** Continuation lines of a multi-line note, indented to sit under its first line. */
18const indented = (text: string, by: string) => text.replace(/\n/g, `\n${by}`)
19const scope = atom({ plugin: 'claudepad', key: 'scope' } as const, 'project' as Scope)
20const query = atom({ plugin: 'claudepad', key: 'query' } as const, '')
21const activeTag = atom({ plugin: 'claudepad', key: 'tag' } as const, null as string | null)
22
23/** A tag is `#` then letters, digits, `_` or `-`, at the start or after a space. */
24const TAG = /(^|\s)#([\p{L}\p{N}_-]+)/gu
25
26/** The note's tags, lowercased, each once, without the `#`. */
27const tagsOf = (text: string) => [...new Set([...text.matchAll(TAG)].map(m => m[2]!.toLowerCase()))]
28
29/** Each tag in `list` with how many notes carry it, most used first. */
30const tagCounts = (list: readonly Note[]) => {
31 const counts = new Map<string, number>()
32 for (const note of list) {
33 for (const tag of tagsOf(note.text)) counts.set(tag, (counts.get(tag) ?? 0) + 1)
34 }
35 return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
36}
37
38const TAG_COLORS = ['#e5a50a', '#3584e4', '#c061cb', '#2ec27e', '#e66100', '#1c9fb8', '#e01b24']
39
40/** The same tag gets the same color everywhere. */
41const colorOf = (tag: string) => {
42 let hash = 0
43 for (const ch of tag) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0
44 return TAG_COLORS[hash % TAG_COLORS.length]!
45}
46
47/**
48 * Every word of the query appears in the note, in any order, any case;
49 * a `#word` must be one of the note's tags exactly.
50 */
51const matches = (note: Note, text: string) => {
52 const haystack = note.text.toLowerCase()
53 const tags = tagsOf(note.text)
54 return text
55 .toLowerCase()
56 .split(/\s+/)
57 .filter(Boolean)
58 .every(word =>
59 word.startsWith('#') && word.length > 1 ? tags.includes(word.slice(1)) : haystack.includes(word),
60 )
61}
62
63const newId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
64
65/** One spelling per folder, so `E:\Code\X` and `e:/code/x/` match. */
66const normalize = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
67
68const projectOf = async ($: EngineInterface) => normalize(await $.session.cwd())
69
70const baseName = (path: string) => path.split('/').pop() || path
71
72const isGlobal = (note: Note) => note.project == null
73
74const isHere = (note: Note, project: string) => note.project === project
75
76const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
77
78const statusFor = (list: readonly Note[], project: string) => {
79 const here = list.filter(note => isHere(note, project) && !note.isDone).length
80 const global = list.filter(note => isGlobal(note) && !note.isDone).length
81 if (here === 0 && global === 0) return undefined
82 return `📝 ${here} here · ${global} global`
83}
84
85/** Changes the notes, keeps them on disk, and refreshes the status line. */
86const change = async ($: EngineInterface, fn: (list: Note[]) => Note[]) => {
87 const written = await update($, notes, list => fn([...list]))
88 await $.store.set(STORE_KEY, written)
89 $.ui.status(statusFor(written, await projectOf($)))
90 return written
91}
92
93const addNote = async ($: EngineInterface, text: string, author: Note['author'], where: Scope) => {
94 const note: Note = {
95 id: newId(),
96 text: text.trim(),
97 isDone: false,
98 isPinned: false,
99 author,
100 createdAt: Date.now(),
101 project: where === 'global' ? null : await projectOf($),
102 }
103 await change($, list => [...list, note])
104 return note
105}
106
107const openPane = ($: EngineInterface) =>
108 $.ui.open({ id: PANE, title: 'claudepad', focus: true })
109
110const lines = (list: readonly Note[]) =>
111 list
112 .map(
113 note =>
114 `- [${note.isDone ? 'x' : ' '}]${note.isPinned ? ' (pinned)' : ''} ${indented(note.text, ' ')}`,
115 )
116 .join('\n')
117
118const asText = (list: readonly Note[], project: string, search = '') => {
119 if (search.trim() !== '') {
120 const found = list.filter(note => matches(note, search))
121 return found.length === 0
122 ? `No notes match "${search.trim()}".`
123 : `${plural(found.length, 'note')} matching "${search.trim()}":\n` +
124 found
125 .map(note => {
126 const where = isGlobal(note) ? 'global' : isHere(note, project) ? 'this project' : baseName(note.project ?? '')
127 return `${lines([note])} (${where})`
128 })
129 .join('\n')
130 }
131 const here = list.filter(note => isHere(note, project))
132 const global = list.filter(isGlobal)
133 return [
134 `Notes for this project (${baseName(project)}):`,
135 here.length ? lines(here) : '(none)',
136 '',
137 'Global notes:',
138 global.length ? lines(global) : '(none)',
139 '',
140 `Tags in use: ${tagCounts(list).map(([tag, n]) => `#${tag} (${n})`).join(', ') || '(none)'}`,
141 ].join('\n')
142}
143
144const EXPORT_FILE = 'NOTES.md'
145/** First line of every export, so a later export knows the file is ours to replace. */
146const EXPORT_MARK = '<!-- exported by the claudepad mod for Claude Code; re-exporting replaces this file -->'
147
148const toMarkdown = (list: readonly Note[], project: string) => {
149 const ordered = (subset: Note[]) =>
150 [...subset].sort(
151 (a, b) =>
152 Number(a.isDone) - Number(b.isDone) ||
153 Number(b.isPinned) - Number(a.isPinned) ||
154 a.createdAt - b.createdAt,
155 )
156 // A multi-line note: its first line on the checkbox, the rest indented two
157 // spaces beneath (a blank line inside it as two spaces, so it stays inside).
158 const item = (note: Note) => {
159 const [first = '', ...rest] = note.text.split('\n')
160 return [
161 `- [${note.isDone ? 'x' : ' '}] ${note.isPinned ? '📌 ' : ''}${first}` +
162 (note.author === 'claude' ? ' _(added by Claude)_' : ''),
163 ...rest.map(line => ` ${line}`),
164 ].join('\n')
165 }
166 const section = (title: string, subset: Note[]) => [
167 `## ${title}`,
168 '',
169 ...(subset.length ? ordered(subset).map(item) : ['_No notes._']),
170 '',
171 ]
172 const here = list.filter(note => isHere(note, project))
173 const global = list.filter(isGlobal)
174 const tags = tagCounts([...here, ...global])
175 return [
176 EXPORT_MARK,
177 `# Notes: ${baseName(project)}`,
178 '',
179 `_Exported ${new Date().toISOString().slice(0, 10)}. ${plural(here.length, 'project note')}, ${plural(global.length, 'global note')}._`,
180 '',
181 ...section(baseName(project), here),
182 ...section('Global', global),
183 ...(tags.length ? ['## Tags', '', tags.map(([tag, n]) => `\`#${tag}\` (${n})`).join(' · '), ''] : []),
184 ].join('\n')
185}
186
187/**
188 * Writes this project's and the global notes to NOTES.md in the session's
189 * folder. A NOTES.md this mod did not write is left alone unless `force`.
190 */
191const exportNotes = async ($: EngineInterface, force: boolean) => {
192 const existing = await $.fs.read(EXPORT_FILE).catch(() => undefined)
193 if (typeof existing === 'string' && !existing.startsWith(EXPORT_MARK) && !force) {
194 return {
195 isWritten: false,
196 message: `${EXPORT_FILE} already exists and wasn't written by the notepad. Run /notes export --force to replace it.`,
197 }
198 }
199 const project = await projectOf($)
200 await $.fs.write(EXPORT_FILE, toMarkdown(await read($, notes), project))
201 return { isWritten: true, message: `📝 Exported notes to ${EXPORT_FILE} in ${baseName(project)}.` }
202}
203
204type ParsedNote = Pick<Note, 'text' | 'isDone' | 'isPinned' | 'author'> & { where: Scope }
205
206/**
207 * Reads the notes out of a NOTES.md: every `- [ ]`/`- [x]` checkbox and every
208 * plain `-`/`*` bullet. Items under a `## Global` heading are global, all
209 * others belong to the project; a `## Tags` section is skipped. Understands
210 * the export's 📌 and "(added by Claude)" marks, and any hand-written list.
211 */
212const parseMarkdown = (markdown: string): ParsedNote[] => {
213 const found: ParsedNote[] = []
214 let where: Scope = 'project'
215 let isSkipped = false
216 // The item an indented, non-bullet line continues (a multi-line note).
217 let current: ParsedNote | null = null
218 for (const raw of markdown.split(/\r?\n/)) {
219 const heading = /^#{2,6}\s+(.*)$/.exec(raw)
220 if (heading) {
221 const title = heading[1]!.trim().toLowerCase().replace(/^[^\p{L}\p{N}]+/u, '')
222 isSkipped = title === 'tags'
223 where = title === 'global' ? 'global' : 'project'
224 current = null
225 continue
226 }
227 if (isSkipped) continue
228 const item = /^\s*[-*+]\s+(?:\[([ xX])\]\s+)?(.*\S)\s*$/.exec(raw)
229 if (!item) {
230 if (current && /^\s/.test(raw)) {
231 current.text += `\n${raw.replace(/^ {1,2}/, '').trimEnd()}`
232 } else {
233 current = null
234 }
235 continue
236 }
237 let text = item[2]!
238 const isPinned = text.startsWith('📌')
239 if (isPinned) text = text.replace(/^📌\s*/u, '')
240 const byClaude = /\s*_\(added by Claude\)_$/.exec(text)
241 if (byClaude) text = text.slice(0, byClaude.index)
242 text = text.trim()
243 if (text === '') {
244 current = null
245 continue
246 }
247 current = {
248 text,
249 isDone: item[1] === 'x' || item[1] === 'X',
250 isPinned,
251 author: byClaude ? 'claude' : 'you',
252 where,
253 }
254 found.push(current)
255 }
256 return found.map(note => ({ ...note, text: note.text.trimEnd() }))
257}
258
259/** Text compared loosely, so spacing and case changes still match a note. */
260const sameText = (a: string, b: string) =>
261 a.replace(/\s+/g, ' ').trim().toLowerCase() === b.replace(/\s+/g, ' ').trim().toLowerCase()
262
263/**
264 * Reads NOTES.md from the session's folder into the notepad. By default it
265 * merges: a note already here (same text and scope) takes the file's done
266 * and pinned marks, a new one is added, and nothing is deleted. With
267 * `replace`, this project's and the global notes become exactly the file's.
268 */
269const importNotes = async ($: EngineInterface, replace: boolean) => {
270 const markdown = await $.fs.read(EXPORT_FILE).catch(() => undefined)
271 if (typeof markdown !== 'string') {
272 return `No ${EXPORT_FILE} in this folder to import.`
273 }
274 const parsed = parseMarkdown(markdown)
275 if (parsed.length === 0) {
276 return `${EXPORT_FILE} has no list items to import (notes are lines starting with "- ").`
277 }
278 const project = await projectOf($)
279 const inScope = (note: Note, where: Scope) =>
280 where === 'global' ? isGlobal(note) : isHere(note, project)
281 let added = 0
282 let updated = 0
283 let removed = 0
284 await change($, all => {
285 // `update` may run this again on a write conflict: count from zero each time.
286 added = 0
287 updated = 0
288 const kept = replace
289 ? all.filter(note => !(isGlobal(note) || isHere(note, project)))
290 : all
291 removed = all.length - kept.length
292 const result = [...kept]
293 const now = Date.now()
294 parsed.forEach((item, i) => {
295 const at = replace
296 ? -1
297 : result.findIndex(note => inScope(note, item.where) && sameText(note.text, item.text))
298 const match = result[at]
299 if (match) {
300 if (match.isDone !== item.isDone || match.isPinned !== item.isPinned) {
301 result[at] = { ...match, isDone: item.isDone, isPinned: item.isPinned }
302 updated += 1
303 }
304 return
305 }
306 result.push({
307 id: newId(),
308 text: item.text,
309 isDone: item.isDone,
310 isPinned: item.isPinned,
311 author: item.author,
312 createdAt: now + i,
313 project: item.where === 'global' ? null : project,
314 })
315 added += 1
316 })
317 return result
318 })
319 if (replace) {
320 return `📝 Replaced ${plural(removed, 'note')} with ${plural(parsed.length, 'note')} from ${EXPORT_FILE}.`
321 }
322 const unchanged = parsed.length - added - updated
323 return `📝 Imported ${EXPORT_FILE}: ${added} added, ${updated} updated, ${unchanged} unchanged.`
324}
325
326/** Opens the multi-line editor on a note's text, or on '' for a new note. */
327const openEditor = async ($: EngineInterface, id: string, initial: string) => {
328 await update($, draft, () => initial)
329 await update($, editor, () => ({ id, initial, line: null }))
330}
331
332const closeEditor = async ($: EngineInterface) => {
333 await update($, editor, () => null)
334 await update($, draft, () => '')
335}
336
337/** Saves the editor's text (or `text`, the editor's own last word) and closes it. */
338const saveEditor = async ($: EngineInterface, text?: string) => {
339 const session = await read($, editor)
340 if (!session) return 'Nothing to save.'
341 // Drop blank lines at the start and whitespace at the end of every line.
342 const body = (text ?? (await read($, draft)))
343 .split('\n')
344 .map(line => line.trimEnd())
345 .join('\n')
346 .replace(/^\n+|\n+$/g, '')
347 if (body.trim() === '') return 'The note is empty: nothing saved.'
348 if (session.id === 'new') {
349 await addNote($, body, 'you', await read($, scope))
350 } else {
351 await change($, all => all.map(note => (note.id === session.id ? { ...note, text: body } : note)))
352 }
353 await closeEditor($)
354 return session.id === 'new' ? '📝 Note added.' : '📝 Note saved.'
355}
356
357/** `/note -g text` or `/note --global text` adds a global note. */
358const parseNoteArgs = (args: string): { text: string; where: Scope } => {
359 const match = /^\s*(-g|--global)\s+/.exec(args)
360 return match
361 ? { text: args.slice(match[0].length).trim(), where: 'global' }
362 : { text: args.trim(), where: 'project' }
363}
364
365export const register: Register = on => {
366 on('session.start', async ($, e, next) => {
367 const saved = await $.store.get(STORE_KEY)
368 const list = Array.isArray(saved) ? (saved as Note[]) : []
369 await update($, notes, () => list)
370 $.ui.status(statusFor(list, await projectOf($)))
371
372 await $.command.register({
373 name: 'notes',
374 description:
375 'Open or close the notepad pane; /notes find <text> searches, /notes tags lists tags, /notes export and /notes import sync NOTES.md',
376 argumentHint: '[find <text> | tags | export | import]',
377 })
378 await $.command.register({
379 name: 'note',
380 description: 'Add a note for this project (-g for a global note, #words become tags)',
381 argumentHint: '[-g] <text>',
382 immediate: true,
383 })
384 await $.tool.register({
385 name: 'add_note',
386 description:
387 "Add a note to the user's notepad (a sidebar they keep across sessions). Use it for follow-ups, TODOs or things to remember later that are not worth doing now. Notes belong to the current project unless `global` is true (use global only for things unrelated to this project). Keep each note short and self-contained. Tag notes with #hashtags in the text (for example #bug, #idea, #todo); prefer tags the user already uses, which list_notes reports.",
388 inputSchema: {
389 type: 'object',
390 properties: {
391 text: { type: 'string', description: 'The note, one or two lines, with any #tags inline.' },
392 global: {
393 type: 'boolean',
394 description: 'True for a note not tied to this project. Default false.',
395 },
396 },
397 required: ['text'],
398 },
399 })
400 await $.tool.register({
401 name: 'list_notes',
402 description:
403 "List the notes in the user's notepad: this project's notes and the global ones, with whether each is done or pinned. With `query`, search every note (all projects) for notes containing all of its words; a #word in the query matches that tag exactly. Also lists the tags in use.",
404 inputSchema: {
405 type: 'object',
406 properties: {
407 query: {
408 type: 'string',
409 description: 'Optional words to search for, case-insensitive; #tag matches a tag (e.g. "#bug login").',
410 },
411 },
412 },
413 })
414
415 return next(e)
416 })
417
418 // /notes toggles the pane.
419 on('command.run', { command: 'notes' }, async ($, e) => {
420 const find = /^\s*(find|search)\b\s*/i.exec(e.args)
421 if (find) {
422 const text = e.args.slice(find[0].length).trim()
423 if (text === '') {
424 return { text: 'Usage: /notes find <text>' }
425 }
426 await update($, query, () => text)
427 return { text: asText(await read($, notes), await projectOf($), text) }
428 }
429 const exportArgs = /^\s*export\b(.*)$/i.exec(e.args)
430 if (exportArgs) {
431 const force = /(^|\s)(-f|--force)(\s|$)/.test(exportArgs[1] ?? '')
432 return { text: (await exportNotes($, force)).message }
433 }
434 const importArgs = /^\s*import\b(.*)$/i.exec(e.args)
435 if (importArgs) {
436 const replace = /(^|\s)--replace(\s|$)/.test(importArgs[1] ?? '')
437 return { text: await importNotes($, replace) }
438 }
439 if (/^\s*tags\s*$/i.test(e.args)) {
440 const counts = tagCounts(await read($, notes))
441 return {
442 text:
443 counts.length === 0
444 ? 'No tags yet. Add #words to a note, e.g. /note fix login #bug'
445 : `Tags: ${counts.map(([tag, n]) => `#${tag} (${n})`).join(' ')}`,
446 }
447 }
448 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
449 if (isUp) {
450 await $.ui.close({ id: PANE })
451 return { text: 'Notepad closed.' }
452 }
453 await openPane($)
454 return { text: 'Notepad opened.' }
455 })
456
457 // /note <text> adds to this project; /note -g <text> adds a global note.
458 on('command.run', { command: 'note' }, async ($, e) => {
459 const { text, where } = parseNoteArgs(e.args)
460 if (text === '') {
461 // A bare /note (or /note -g) opens the multi-line editor on a new note.
462 await update($, scope, () => where)
463 await openEditor($, 'new', '')
464 await openPane($)
465 return { text: 'Multi-line editor opened. Or add a quick note with /note <text> (-g for global).' }
466 }
467 await addNote($, text, 'you', where)
468 const label = where === 'global' ? 'global' : baseName(await projectOf($))
469 return { text: `📝 Noted (${label}): ${text}` }
470 })
471
472 // Claude-aware: pinned notes (this project's and global) ride along with every prompt.
473 on('prompt.submit', async ($, e, next) => {
474 const project = await projectOf($)
475 const pinned = (await read($, notes)).filter(
476 note => note.isPinned && !note.isDone && (isGlobal(note) || isHere(note, project)),
477 )
478 if (pinned.length === 0) {
479 return next(e)
480 }
481 const block =
482 "The user's pinned notes from their notepad (standing context; keep them in mind):\n" +
483 pinned
484 .map(note => `- ${indented(note.text, ' ')}${isGlobal(note) ? '' : ' (this project)'}`)
485 .join('\n')
486 return next({ ...e, context: [...(e.context ?? []), block] })
487 })
488
489 // Claude-aware: tools so Claude can write and read notes.
490 on('tool.call', { tool: ADD_TOOL }, async ($, e) => {
491 const input = e as unknown as { text?: unknown; global?: unknown }
492 const text = String(input.text ?? '').trim()
493 if (text === '') {
494 return { deny: 'add_note needs non-empty text.' }
495 }
496 const where: Scope = input.global === true ? 'global' : 'project'
497 await addNote($, text, 'claude', where)
498 $.ui.toast(`📝 Claude added a ${where === 'global' ? 'global' : 'project'} note: ${text.slice(0, 60)}`)
499 return { result: `Added to the notepad (${where}): ${text}` }
500 })
501
502 on('tool.call', { tool: LIST_TOOL }, async ($, e) => {
503 const search = (e as unknown as { query?: unknown }).query
504 return {
505 result: asText(await read($, notes), await projectOf($), typeof search === 'string' ? search : ''),
506 }
507 })
508
509 // The editor surface module posts its text as it changes, and on Ctrl+S.
510 on('ui.message', async ($, e, next) => {
511 if (e.requestId !== PANE || !e.element.startsWith('editor-')) {
512 return next(e)
513 }
514 const data = e.data as { type?: unknown; text?: unknown } | null
515 if (typeof data?.text !== 'string') {
516 return {}
517 }
518 const text = data.text
519 if (data.type === 'draft') {
520 await update($, draft, () => text)
521 } else if (data.type === 'save') {
522 $.ui.toast(await saveEditor($, text))
523 }
524 return {}
525 })
526
527 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
528 const els = $.ui.resolve(e)
529 const { Box, Text, Button } = els
530 const Input = 'Input' in els ? els.Input : undefined
531 const project = await projectOf($)
532 const list = await read($, notes)
533 const where = await read($, scope)
534
535 const session = await read($, editor)
536 if (session) {
537 const Client = 'Client' in els ? els.Client : undefined
538 const isLines = !Client || (await read($, isLineMode))
539 const isNew = session.id === 'new'
540 const heading = isNew
541 ? `✎ New note (${where === 'global' ? 'global' : baseName(project)})`
542 : '✎ Editing note'
543 const save = async () => $.ui.toast(await saveEditor($))
544
545 // Line mode: the draft as numbered lines, one Input adding or replacing a line.
546 const lineView = async () => {
547 const text = await read($, draft)
548 const all = text === '' ? [] : text.split('\n')
549 const setLines = (fn: (lines: string[]) => string[]) =>
550 update($, draft, current => fn(current === '' ? [] : current.split('\n')).join('\n'))
551 const editLine = (line: number | null) =>
552 update($, editor, current => (current ? { ...current, line } : current))
553 const target = session.line !== null && session.line < all.length ? session.line : null
554 return (
555 <Box flexDirection="column">
556 {all.length === 0 && <Text dimColor>Empty. Type the first line below and press Enter.</Text>}
557 {all.map((line, i) => (
558 <Box key={`line-${i}`} gap={1}>
559 <Text dimColor={target !== null && target !== i} bold={target === i}>
560 {`${String(i + 1).padStart(String(all.length).length)} │ ${line}`}
561 </Text>
562 <Button key={`line-edit-${i}`} plain dimColor onPress={() => editLine(i)}>
563 edit
564 </Button>
565 <Button
566 key={`line-del-${i}`}
567 plain
568 dimColor
569 onPress={async () => {
570 await setLines(lines => lines.filter((_, at) => at !== i))
571 await editLine(null)
572 }}
573 >
574 ×
575 </Button>
576 </Box>
577 ))}
578 {Input && (
579 <Input
580 key={target === null ? `line-add-${all.length}` : `line-set-${target}`}
581 label={target === null ? `${all.length + 1} + ` : `${target + 1} ✎ `}
582 placeholder={target === null ? 'Next line; Enter adds it (empty adds a blank line)' : 'Enter replaces the line; empty deletes it'}
583 value={target === null ? '' : all[target] ?? ''}
584 submitLabel={target === null ? 'add line' : 'replace'}
585 autoFocus
586 onSubmit={async value => {
587 if (target === null) {
588 await setLines(lines => [...lines, value])
589 } else {
590 await setLines(lines =>
591 value === '' ? lines.filter((_, at) => at !== target) : lines.map((l, at) => (at === target ? value : l)),
592 )
593 await editLine(null)
594 }
595 }}
596 />
597 )}
598 {target !== null && (
599 <Button key="line-stop" plain dimColor onPress={() => editLine(null)}>
600 back to adding lines
601 </Button>
602 )}
603 </Box>
604 )
605 }
606
607 return (
608 <Box flexDirection="column">
609 <Text bold>{heading}</Text>
610 {isLines || !Client ? (
611 await lineView()
612 ) : (
613 <Box flexDirection="column">
614 <Client key={`editor-${session.id}`} module="./editor.tsx" props={{ text: session.initial }} />
615 <Text dimColor>
616 Click the box to type · Enter new line · Ctrl+S save · arrows, Home/End, PgUp/PgDn move · Esc leaves the box
617 </Text>
618 </Box>
619 )}
620 <Box marginTop={1} gap={2}>
621 <Button key="editor-save" variant="primary" onPress={save}>
622 Save
623 </Button>
624 <Button key="editor-cancel" role="dismiss" onPress={() => closeEditor($)}>
625 Cancel
626 </Button>
627 {Client && (
628 <Button
629 key="editor-mode"
630 plain
631 dimColor
632 onPress={async () => {
633 const text = await read($, draft)
634 // Back to the full editor: it reopens on the text as it stands now.
635 await update($, editor, current => (current ? { ...current, initial: text, line: null } : current))
636 await update($, isLineMode, value => !value)
637 }}
638 >
639 {isLines ? 'switch to full editor' : 'switch to line editor'}
640 </Button>
641 )}
642 </Box>
643 </Box>
644 )
645 }
646
647 const search = (await read($, query)).trim()
648 const tag = await read($, activeTag)
649 const isSearching = search !== '' || tag !== null
650 const shown = (note: Note) =>
651 (search === '' || matches(note, search)) && (tag === null || tagsOf(note.text).includes(tag))
652
653 const ordered = (subset: Note[]) =>
654 [...subset].sort(
655 (a, b) =>
656 Number(b.isPinned) - Number(a.isPinned) ||
657 Number(a.isDone) - Number(b.isDone) ||
658 a.createdAt - b.createdAt,
659 )
660 const allHere = list.filter(note => isHere(note, project))
661 const allGlobal = list.filter(isGlobal)
662 const here = ordered(allHere.filter(shown))
663 const global = ordered(allGlobal.filter(shown))
664 // Only while searching: matches from every other project too.
665 const elsewhere = isSearching
666 ? ordered(list.filter(note => !isGlobal(note) && !isHere(note, project) && shown(note)))
667 : []
668
669 const submit = async (value: string) => {
670 const text = value.trim()
671 if (text !== '') {
672 await addNote($, text, 'you', where)
673 }
674 }
675
676 const toggle = (id: string, field: 'isDone' | 'isPinned') =>
677 change($, all => all.map(note => (note.id === id ? { ...note, [field]: !note[field] } : note)))
678
679 const move = (id: string) =>
680 change($, all =>
681 all.map(note =>
682 note.id === id ? { ...note, project: isGlobal(note) ? project : null } : note,
683 ),
684 )
685
686 /** The note's text with each #tag drawn in its color. */
687 const withTags = (text: string, isDone: boolean) => {
688 const parts: (string | ReturnType<typeof h>)[] = []
689 let at = 0
690 for (const m of text.matchAll(TAG)) {
691 const start = m.index! + m[1]!.length
692 if (start > at) parts.push(text.slice(at, start))
693 const word = text.slice(start, m.index! + m[0].length)
694 parts.push(
695 isDone ? word : <Text color={colorOf(m[2]!.toLowerCase())}>{word}</Text>,
696 )
697 at = m.index! + m[0].length
698 }
699 if (at < text.length) parts.push(text.slice(at))
700 return parts
701 }
702
703 const row = (note: Note) => (
704 <Box key={`row-${note.id}`} flexDirection="column" marginTop={1}>
705 <Text dimColor={note.isDone} strikethrough={note.isDone} bold={note.isPinned}>
706 {note.isDone ? '☑' : '☐'} {note.isPinned ? '📌 ' : ''}
707 {note.author === 'claude' ? '🤖 ' : ''}
708 {withTags(note.text, note.isDone)}
709 {!isGlobal(note) && !isHere(note, project) ? ` (${baseName(note.project ?? '')})` : ''}
710 </Text>
711 <Box gap={1}>
712 <Button key={`done-${note.id}`} plain dimColor onPress={() => toggle(note.id, 'isDone')}>
713 {note.isDone ? 'undo' : 'done'}
714 </Button>
715 <Button key={`pin-${note.id}`} plain dimColor onPress={() => toggle(note.id, 'isPinned')}>
716 {note.isPinned ? 'unpin' : 'pin'}
717 </Button>
718 <Button
719 key={`send-${note.id}`}
720 plain
721 dimColor
722 onPress={() => void $.prompt.fill({ text: note.text, mode: 'insert' })}
723 >
724 → prompt
725 </Button>
726 <Button key={`edit-${note.id}`} plain dimColor onPress={() => openEditor($, note.id, note.text)}>
727 edit
728 </Button>
729 <Button key={`move-${note.id}`} plain dimColor onPress={() => move(note.id)}>
730 {isGlobal(note) ? '→ project' : '→ global'}
731 </Button>
732 <Button
733 key={`del-${note.id}`}
734 plain
735 dimColor
736 onPress={() => change($, all => all.filter(other => other.id !== note.id))}
737 >
738 delete
739 </Button>
740 </Box>
741 </Box>
742 )
743
744 const visible = [...here, ...global]
745 // Tags of this project's and the global notes; an active tag stays listed even if it empties.
746 const tagBar = tagCounts([...allHere, ...allGlobal])
747 if (tag !== null && !tagBar.some(([name]) => name === tag)) tagBar.unshift([tag, 0])
748 const openCount = (subset: Note[]) => subset.filter(note => !note.isDone).length
749 const countLabel = (found: Note[], all: Note[]) =>
750 isSearching
751 ? `${found.length} of ${plural(all.length, 'note')} match`
752 : plural(openCount(all), 'open note')
753
754 return (
755 <Box flexDirection="column">
756 {Input && (
757 <Input
758 key={`new-${where}`}
759 label={where === 'global' ? '+ global: ' : `+ ${baseName(project)}: `}
760 placeholder="Write a note and press Enter"
761 value=""
762 submitLabel="add"
763 autoFocus
764 onSubmit={value => void submit(value)}
765 />
766 )}
767 <Box gap={1}>
768 <Text dimColor>Add to:</Text>
769 <Button
770 key="scope-project"
771 plain
772 dimColor={where !== 'project'}
773 onPress={() => update($, scope, () => 'project')}
774 >
775 {where === 'project' ? '● project' : '○ project'}
776 </Button>
777 <Button
778 key="scope-global"
779 plain
780 dimColor={where !== 'global'}
781 onPress={() => update($, scope, () => 'global')}
782 >
783 {where === 'global' ? '● global' : '○ global'}
784 </Button>
785 <Button key="new-multiline" plain onPress={() => openEditor($, 'new', '')}>
786 ✎ multi-line note
787 </Button>
788 </Box>
789
790 {Input && (
791 <Box marginTop={1} gap={1}>
792 <Input
793 key="search"
794 label="🔍 "
795 placeholder="Search notes"
796 value={search}
797 submitLabel="search"
798 onInput={value => void update($, query, () => value)}
799 onSubmit={value => void update($, query, () => value)}
800 />
801 {search !== '' && (
802 <Button key="clear-search" plain dimColor onPress={() => update($, query, () => '')}>
803 clear
804 </Button>
805 )}
806 </Box>
807 )}
808
809 {tagBar.length > 0 && (
810 <Box gap={1} flexWrap="wrap">
811 <Text dimColor>Tags:</Text>
812 {tagBar.map(([name, n]) => (
813 <Button
814 key={`tag-${name}`}
815 plain
816 dimColor={tag !== null && tag !== name}
817 onPress={() => update($, activeTag, current => (current === name ? null : name))}
818 >
819 {`${tag === name ? '● ' : ''}#${name} ${n}`}
820 </Button>
821 ))}
822 {tag !== null && (
823 <Button key="clear-tag" plain dimColor onPress={() => update($, activeTag, () => null)}>
824 all tags
825 </Button>
826 )}
827 </Box>
828 )}
829
830 <Box marginTop={1}>
831 <Text bold>📁 {baseName(project)}</Text>
832 <Text dimColor> ({countLabel(here, allHere)})</Text>
833 </Box>
834 {here.length === 0 && (
835 <Text dimColor>{isSearching ? 'No matches here.' : 'No notes for this project yet.'}</Text>
836 )}
837 {here.map(row)}
838
839 <Box marginTop={1}>
840 <Text bold>🌐 Global</Text>
841 <Text dimColor> ({countLabel(global, allGlobal)})</Text>
842 </Box>
843 {global.length === 0 && (
844 <Text dimColor>{isSearching ? 'No matches here.' : 'No global notes yet. Try /note -g buy milk'}</Text>
845 )}
846 {global.map(row)}
847
848 {elsewhere.length > 0 && (
849 <Box marginTop={1}>
850 <Text bold>🗂 Other projects</Text>
851 <Text dimColor> ({plural(elsewhere.length, 'match')})</Text>
852 </Box>
853 )}
854 {elsewhere.map(row)}
855
856 <Box marginTop={1} gap={2}>
857 <Button
858 key="export"
859 plain
860 dimColor
861 onPress={async () => $.ui.toast((await exportNotes($, false)).message)}
862 >
863 export to NOTES.md
864 </Button>
865 <Button
866 key="import"
867 plain
868 dimColor
869 onPress={async () => $.ui.toast(await importNotes($, false))}
870 >
871 import from NOTES.md
872 </Button>
873 {visible.some(note => note.isDone) && (
874 <Button
875 key="clear-done"
876 plain
877 dimColor
878 onPress={() =>
879 change($, all =>
880 all.filter(note => !(note.isDone && (isGlobal(note) || isHere(note, project)))),
881 )
882 }
883 >
884 clear done
885 </Button>
886 )}
887 </Box>
888 </Box>
889 )
890 })
891}
892hooks/editor.tsx 165 lines1// The notepad's multi-line editor: a surface module drawn by a `Client` in
2// the pane. It runs on the drawing side, keeps the text and cursor in its
3// own local state, and posts to the hooks module: `{ type: 'draft', text }`
4// on every change, `{ type: 'save', text }` on Ctrl+S.
5import type { ClientKeyEvent, ClientModule } from 'claude-code'
6
7type Props = { text: string }
8type State = { lines: string[]; row: number; col: number }
9
10/** Lines drawn at once; the window follows the cursor. */
11const WINDOW = 14
12
13/** Key names that are never typed text. */
14const SPECIAL = new Set([
15 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete',
16 'pageup', 'pagedown', 'home', 'end', 'escape', 'insert',
17])
18
19const fromText = (text: string): State => {
20 const lines = text.split('\n')
21 const row = lines.length - 1
22 return { lines, row, col: lines[row]!.length }
23}
24
25const toText = (state: State) => state.lines.join('\n')
26
27const insert = (state: State, typed: string): State => {
28 const pieces = typed.replace(/\r\n?/g, '\n').split('\n')
29 const line = state.lines[state.row]!
30 const before = line.slice(0, state.col)
31 const after = line.slice(state.col)
32 const last = pieces.length - 1
33 const added = pieces.map((piece, i) =>
34 (i === 0 ? before : '') + piece + (i === last ? after : ''),
35 )
36 const lines = [...state.lines.slice(0, state.row), ...added, ...state.lines.slice(state.row + 1)]
37 const row = state.row + last
38 const col = (last === 0 ? before.length : 0) + pieces[last]!.length
39 return { lines, row, col }
40}
41
42/** The next state for one key, or 'save', or the same state when nothing changes. */
43const apply = (state: State, event: ClientKeyEvent): State | 'save' => {
44 const { lines, row, col } = state
45 const line = lines[row]!
46 const key = event.key
47
48 if (event.ctrl && key.toLowerCase() === 's') return 'save'
49
50 switch (key) {
51 case 'return':
52 case 'enter':
53 return insert(state, '\n')
54 case 'tab':
55 return insert(state, ' ')
56 case 'backspace':
57 if (col > 0) {
58 const next = [...lines]
59 next[row] = line.slice(0, col - 1) + line.slice(col)
60 return { lines: next, row, col: col - 1 }
61 }
62 if (row > 0) {
63 const above = lines[row - 1]!
64 const next = [...lines.slice(0, row - 1), above + line, ...lines.slice(row + 1)]
65 return { lines: next, row: row - 1, col: above.length }
66 }
67 return state
68 case 'delete':
69 if (col < line.length) {
70 const next = [...lines]
71 next[row] = line.slice(0, col) + line.slice(col + 1)
72 return { lines: next, row, col }
73 }
74 if (row < lines.length - 1) {
75 const next = [...lines.slice(0, row), line + lines[row + 1]!, ...lines.slice(row + 2)]
76 return { lines: next, row, col }
77 }
78 return state
79 case 'left':
80 if (col > 0) return { lines, row, col: col - 1 }
81 if (row > 0) return { lines, row: row - 1, col: lines[row - 1]!.length }
82 return state
83 case 'right':
84 if (col < line.length) return { lines, row, col: col + 1 }
85 if (row < lines.length - 1) return { lines, row: row + 1, col: 0 }
86 return state
87 case 'up':
88 if (row === 0) return { lines, row, col: 0 }
89 return { lines, row: row - 1, col: Math.min(col, lines[row - 1]!.length) }
90 case 'down':
91 if (row === lines.length - 1) return { lines, row, col: line.length }
92 return { lines, row: row + 1, col: Math.min(col, lines[row + 1]!.length) }
93 case 'pageup': {
94 const to = Math.max(0, row - WINDOW)
95 return { lines, row: to, col: Math.min(col, lines[to]!.length) }
96 }
97 case 'pagedown': {
98 const to = Math.min(lines.length - 1, row + WINDOW)
99 return { lines, row: to, col: Math.min(col, lines[to]!.length) }
100 }
101 case 'home':
102 return { lines, row, col: 0 }
103 case 'end':
104 return { lines, row, col: line.length }
105 }
106
107 // Anything else that isn't a named key is text: one character, or a paste.
108 if (event.ctrl || event.meta || SPECIAL.has(key) || key === '') return state
109 return insert(state, key)
110}
111
112const Editor: ClientModule<Props, State> = (props, surface) => {
113 const { Box, Text } = surface.elements
114 const state = surface.state ?? fromText(props.text)
115
116 surface.onKey(event => {
117 const current = surface.state ?? fromText(props.text)
118 const next = apply(current, event)
119 if (next === 'save') {
120 surface.post({ type: 'save', text: toText(current) })
121 return
122 }
123 if (next !== current) {
124 surface.setState(next)
125 surface.post({ type: 'draft', text: toText(next) })
126 }
127 })
128
129 const start = Math.max(0, Math.min(state.row - Math.floor(WINDOW / 2), state.lines.length - WINDOW))
130 const shown = state.lines.slice(start, start + WINDOW)
131 const gutter = String(state.lines.length).length
132
133 return (
134 <Box flexDirection="column" borderStyle="round" paddingX={1}>
135 {start > 0 && <Text dimColor>{` ↑ ${start} more`}</Text>}
136 {shown.map((line, i) => {
137 const row = start + i
138 const number = <Text dimColor>{`${String(row + 1).padStart(gutter)} │ `}</Text>
139 if (row !== state.row) {
140 return (
141 <Text>
142 {number}
143 {line}
144 </Text>
145 )
146 }
147 const at = line[state.col] ?? ' '
148 return (
149 <Text>
150 {number}
151 {line.slice(0, state.col)}
152 <Text inverse>{at}</Text>
153 {line.slice(state.col + 1)}
154 </Text>
155 )
156 })}
157 {start + WINDOW < state.lines.length && (
158 <Text dimColor>{` ↓ ${state.lines.length - start - WINDOW} more`}</Text>
159 )}
160 </Box>
161 )
162}
163
164export default Editor
165types/index.d.ts 48 lines1export type Note = {
2 id: string
3 /** The note; may span several lines. */
4 text: string
5 isDone: boolean
6 isPinned: boolean
7 /** Who wrote it: the person, or Claude through the add_note tool. */
8 author: 'you' | 'claude'
9 createdAt: number
10 /**
11 * The project folder the note belongs to (normalized path), or null for a
12 * global note. Notes saved before projects existed have none: global.
13 */
14 project?: string | null
15}
16
17/** Where the pane's input adds new notes. */
18export type Scope = 'project' | 'global'
19
20/** The multi-line editor while it is open. */
21export type EditorSession = {
22 /** The note being edited, or 'new' for a note not saved yet. */
23 id: string
24 /** The text the editor opened with. */
25 initial: string
26 /** Line mode only: the line the input edits, or null to add a line. */
27 line: number | null
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 claudepad: {
33 notes: Note[]
34 /** The open multi-line editor, or null. */
35 editor: EditorSession | null
36 /** The editor's current text, kept as it is typed. */
37 draft: string
38 /** Use the line-by-line editor even where the full editor can draw. */
39 isLineMode: boolean
40 scope: Scope
41 /** The pane's search text; empty shows every note. */
42 query: string
43 /** The tag the pane filters on (lowercase, no `#`), or null for all. */
44 tag: string | null
45 }
46 }
47}
48