SLOPSHOPPER

notepad

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

panecommand
v0.1.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/notepad
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notepad
│ ┃ Notepad ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./editor.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /notepad │ ⎿ notepad: notepad shown · click the note to edit it │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notepad
▣ client module ./editor.tsx
README

notepad

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

Install

/plugin install notepad --marketplace dboeter/cc-plugins

Answer y to add the marketplace, then pick a scope.

Use

/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.

What it does on your machine

  • Storage: the note's text in the plugin's own store (a JSON file under your Claude Code configuration directory), one entry per project root. /cd switches to that directory's note; a shell cd does not. All notes together are capped at 4 MiB.
  • Claude: nothing is added to the prompt or the context.
Source 2 files
hooks/notepad.tsx 56 lines
1import 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}
56
hooks/editor.tsx 139 lines
1// 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