A notepad pane beside the session, one per project, kept between sessions. /notepad toggles it. Claude does not see it.

A notepad pane beside the session: one note per project, kept between sessions. Claude does not see it.
/plugin install notepad --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
/notepad shows or hides the pane. It never opens by itself.
The note is markdown, and the pane shows it rendered. Click the note to edit it: the pane turns into a plain-text editor with the keyboard already in it. Enter starts a new line at the cursor, the arrow keys, Home, End, Page Up and Page Down move it (Ctrl+A and Ctrl+E go to the line's start and end), and a click places it. The note saves on every keystroke. Click [ Preview ] on the editor's top row to see it rendered again. Esc returns the keyboard to the prompt.
Why a click: Claude Code hands the keyboard to a mod's own editor only on a click, not when /notepad opens the pane or on the focus chord. The built-in text field would take the keyboard on open, but it draws its ⏎ · \⏎ new line hint level with the first line at the width of the longest one, and Enter there could not start a new line at the cursor.
In VS Code, which cannot run the editor, the pane is the built-in text field (Enter saves; type a newline as in the prompt box). On the mobile app the note is shown rendered, read-only.
/cd switches to that directory's note; a shell cd does not. All notes together are capped at 4 MiB.hooks/notepad.tsx 56 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const PANE = 'notepad'
4const TITLE = 'Notepad'
5
6// one note per project: keyed by the session's project root, which /cd moves but a shell `cd` does not
7export const keyOf = (root: string) => `notes:${root}`
8let typed: { root: string; text: string } | undefined
9const isUp = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
10
11export const register: Register = on => {
12 on('session.start', async ($, e, next) => {
13 try { await $.command.register({ name: 'notepad', description: 'Toggle a notepad pane for this project. Claude does not see it.', immediate: true }) }
14 catch (err) { $.ui.log(`notepad: /notepad not registered: ${(err as Error)?.message ?? err}`, { to: 'debug' }) } // throws on hot reload (no session bound yet)
15 return next(e)
16 })
17
18 on('command.run', { command: 'notepad' }, async $ => {
19 const up = await isUp($)
20 if (up) await $.ui.close({ id: PANE })
21 else await $.ui.open({ id: PANE, title: TITLE, focus: true })
22 return { text: up ? 'notepad hidden' : 'notepad shown · click the note to edit it' }
23 })
24
25 on('ui.message', async ($, e, next) => {
26 if (e.requestId !== PANE || e.element !== 'editor') return next(e)
27 const text = (e.data as { text?: unknown } | null)?.text
28 if (typeof text === 'string') { const root = await $.session.root(); typed = { root, text }; await $.store.set(keyOf(root), text) }
29 return next(e)
30 })
31
32 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
33 const root = await $.session.root()
34 // the text typed last wins over the store: a redraw landing before its save settles would otherwise put the field back
35 const notes = typed?.root === root ? typed.text : String((await $.store.get(keyOf(root))) ?? '')
36 const keep = (text: string) => { typed = { root, text }; void $.store.set(keyOf(root), text) }
37 const rows = Math.max(3, e.props.scroll?.bodyRows ?? 20)
38 if (e.surface === 'mobile') { // no Input or Client there: the note rendered, read-only
39 const { Box, Text, Markdown } = $.ui.resolve(e)
40 return <Box flexDirection="column">{notes ? <Markdown text={notes} /> : <Text dimColor>No notes for this project.</Text>}</Box>
41 }
42 if (e.surface === 'vscode') { // no Client there: the engine's own field, whose Enter only saves
43 const { Box, Input } = $.ui.resolve(e)
44 return <Box flexDirection="column"><Input key="notes" rows={rows - 1} autoFocus value={notes} placeholder="Markdown notes for this project" submitLabel="save" onInput={keep} onSubmit={keep} /></Box>
45 }
46 // our own editor (editor.tsx), which also draws the rendered note: the engine's multi-row Input draws its hint level
47 // with its first row at its widest line, and keeps the cursor to itself. Each edit comes back as ui.message
48 const { Box, Client } = $.ui.resolve(e)
49 return (
50 <Box flexDirection="column">
51 <Client key="editor" module="./editor.tsx" width="100%" height={rows} props={{ text: notes, root, columns: e.props.bodyColumns, rows }} />
52 </Box>
53 )
54 })
55}
56hooks/editor.tsx 139 lines1// The notepad's editor: a Client surface module, run on the drawing thread with no `$`. It owns the text and the cursor
2// (which the engine's Input keeps to itself), so Enter puts a newline at the cursor, and draws no hint of its own. Each
3// edit is posted to the hooks module, which saves it.
4//
5// It also draws the rendered view, because only a click on a Client gives it the keyboard: `focus: true`, the focus
6// chord and `$.ui.focus` do not, the pane's `isFocused` stays false while it has the keys, and Escape (which takes them
7// back) never reaches it. So a click on the rendered note opens the editor with the keys already in it, and a click on
8// the Preview row goes back.
9import type { ClientKeyEvent, ClientModule } from 'claude-code'
10
11export type EditorProps = { text: string; root: string; columns: number; rows: number }
12export type Editor = { text: string; cur: number; goal?: number }
13type State = Editor & { root: string; top: number; editing: boolean }
14export type Row = { start: number; len: number; final: boolean }
15
16// Soft-wraps the text into rows of at most `cols - 1` characters, the last column kept for the cursor at a line's end.
17// A row that is not its line's last is full, so the offset at its end is the next row's start.
18export const layout = (text: string, cols: number): Row[] => {
19 const w = Math.max(1, cols - 1)
20 const rows: Row[] = []
21 let off = 0
22 for (const line of text.split('\n')) {
23 for (let s = 0; ; s += w) {
24 const final = line.length - s <= w
25 rows.push({ start: off + s, len: final ? line.length - s : w, final })
26 if (final) break
27 }
28 off += line.length + 1
29 }
30 return rows
31}
32
33export const rowOf = (rows: Row[], cur: number) =>
34 Math.max(0, rows.findIndex(r => cur >= r.start && (cur < r.start + r.len || (r.final && cur === r.start + r.len))))
35
36// the offset under a cell of a row, past its end landing on its last place
37export const offsetAt = (rows: Row[], row: number, x: number) => {
38 const r = rows[Math.min(rows.length - 1, Math.max(0, row))]!
39 return r.start + Math.max(0, Math.min(x, r.final ? r.len : r.len - 1))
40}
41
42// the first row drawn: the one before, moved only as far as it takes to keep the cursor's row in view
43export const topFor = (ed: Editor, top: number, cols: number, height: number) => {
44 const at = rowOf(layout(ed.text, cols), ed.cur)
45 return Math.min(Math.max(top, at - height + 1), at)
46}
47
48// the engine names special keys by word, `space` included; one it names that is not handled here is dropped, never typed
49// (a typed word arrives as itself only in a paste or a burst of keys, which no name below is likely to be)
50const NAMED = new Set(['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown', 'backspace', 'delete', 'return',
51 'enter', 'tab', 'escape', 'space', 'insert', 'clear', ...Array.from({ length: 24 }, (_, i) => `f${i + 1}`)])
52// one UTF-16 unit, or two when they make one character, so a step never splits an emoji
53const stepBack = (t: string, i: number) => (i >= 2 && /[\uDC00-\uDFFF]/.test(t[i - 1]!) && /[\uD800-\uDBFF]/.test(t[i - 2]!) ? 2 : 1)
54const stepFwd = (t: string, i: number) => (/[\uD800-\uDBFF]/.test(t[i] ?? '') && /[\uDC00-\uDFFF]/.test(t[i + 1] ?? '') ? 2 : 1)
55
56// One key applied to the text and cursor; `page` is how many rows a page key moves. A key it does not handle returns
57// the editor unchanged (the same object), so the caller can skip a redraw.
58export const apply = (ed: Editor, k: ClientKeyEvent, cols: number, page: number): Editor => {
59 const { text, cur } = ed
60 const insert = (s: string): Editor => ({ text: text.slice(0, cur) + s + text.slice(cur), cur: cur + s.length })
61 const vertical = (by: number): Editor => {
62 const rows = layout(text, cols)
63 const at = rowOf(rows, cur)
64 const goal = ed.goal ?? cur - rows[at]!.start
65 return { text, cur: offsetAt(rows, at + by, goal), goal }
66 }
67 const lineStart = text.lastIndexOf('\n', cur - 1) + 1
68 const lineEnd = (n => (n < 0 ? text.length : n))(text.indexOf('\n', cur))
69 if (k.ctrl && k.key === 'a') return { text, cur: lineStart }
70 if (k.ctrl && k.key === 'e') return { text, cur: lineEnd }
71 if (k.ctrl || k.meta) return ed
72 switch (k.key) {
73 case 'left': return { text, cur: Math.max(0, cur - stepBack(text, cur)) }
74 case 'right': return { text, cur: Math.min(text.length, cur + stepFwd(text, cur)) }
75 case 'up': return vertical(-1)
76 case 'down': return vertical(1)
77 case 'pageup': return vertical(-page)
78 case 'pagedown': return vertical(page)
79 case 'home': return { text, cur: lineStart }
80 case 'end': return { text, cur: lineEnd }
81 case 'return': case 'enter': return insert('\n')
82 case 'space': return insert(' ')
83 case 'tab': return insert(' ')
84 case 'backspace': return cur === 0 ? ed : (n => ({ text: text.slice(0, cur - n) + text.slice(cur), cur: cur - n }))(stepBack(text, cur))
85 case 'delete': return cur === text.length ? ed : { text: text.slice(0, cur) + text.slice(cur + stepFwd(text, cur)), cur }
86 }
87 if (NAMED.has(k.key) || k.key.length === 0) return ed
88 // a typed character, or a paste in one piece: line ends made newlines, tabs spaces, other control characters dropped
89 const typed = k.key.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '')
90 return typed ? insert(typed) : ed
91}
92
93const NotepadEditor: ClientModule<EditorProps, State> = (props, surface) => {
94 const { Box, Text, Markdown } = surface.elements
95 const cols = Math.max(2, surface.columns || props.columns)
96 const height = Math.max(1, (surface.rows || props.rows) - 1) // the editor's first row is the Preview row
97 // a new project (a /cd while the pane is open) starts from its own note, rendered
98 const fresh = (): State => ({ text: props.text, cur: props.text.length, root: props.root, top: 0, editing: false })
99 const current = () => (surface.state?.root === props.root ? surface.state : fresh())
100 const st = current()
101 surface.onKey(k => {
102 const now = current()
103 if (!now.editing) return
104 const next = apply(now, k, cols, height - 1)
105 if (next === now) return
106 if (next.text !== now.text) surface.post({ text: next.text })
107 surface.setState({ ...now, ...next, goal: next.goal, top: topFor(next, now.top, cols, height) })
108 })
109 surface.onPointer(p => {
110 if (p.type !== 'down' || p.button !== 'left') return
111 const now = current()
112 if (!now.editing) return surface.setState({ ...now, editing: true, goal: undefined }) // the click gave it the keys
113 if (p.y === 0) return surface.setState({ ...now, editing: false })
114 const cur = offsetAt(layout(now.text, cols), now.top + p.y - 1, p.x)
115 surface.setState({ ...now, cur, goal: undefined })
116 })
117 if (!st.editing) return (
118 <Box flexDirection="column">
119 {st.text ? <Markdown text={st.text} /> : <Text dimColor>No notes for this project. Click here to write.</Text>}
120 </Box>
121 )
122 const rows = layout(st.text, cols)
123 const at = rowOf(rows, st.cur)
124 const top = topFor(st, st.top, cols, height) // again here, as a resize moves rows without a key
125 return (
126 <Box flexDirection="column">
127 <Text dimColor>[ Preview ] markdown · click to place the cursor · Esc leaves</Text>
128 {rows.slice(top, top + height).map((r, i) => {
129 const line = st.text.slice(r.start, r.start + r.len)
130 if (top + i !== at) return <Text>{line || ' '}</Text>
131 const x = st.cur - r.start
132 const n = stepFwd(line, x)
133 return <Text>{line.slice(0, x)}<Text inverse>{line.slice(x, x + n) || ' '}</Text>{line.slice(x + n)}</Text>
134 })}
135 </Box>
136 )
137}
138export default NotepadEditor
139