A notes pane: jot text during a session, keep it across sessions, arrow to a note and press Enter to paste it into the prompt.

A notes pane for Claude Code. Jot text down during a session, keep it across sessions, and paste a note into the prompt without leaving the keyboard.
/notes opens the pane with the keyboard (from an empty prompt).| Key | Does |
|---|---|
| ↑ ↓ | Move between notes |
| Enter | Paste the selected note at the cursor |
| 1–9 | Paste note 1 to 9 |
| e | Edit the selected note |
| p | Pin it to the top, or unpin it |
| d | Delete it (u undoes) |
| u | Bring back the last deleted note |
| c | Copy it to the clipboard |
| r | Replace the whole prompt with it |
| a | Write a new note |
| g | Save the mouse selection as a note (fullscreen) |
| l | Save your last prompt as a note |
| q | Back to the prompt |
| k | Show or hide the key list |
The selected note opens to show the rest of its text, rendered as Markdown.
/notes: open the pane. /notes 3 pastes note 3 without opening it./note <text>: save a note. /note on its own saves the mouse selection.Notes are kept in the plugin store, so they survive restarts and are shared by open sessions.
hooks/register.tsx 439 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, UiOpenResult } from 'claude-code'
3
4import type { Note } from '../types'
5import type { ActionKey } from './keys'
6import { addNote, editNote, idOfRowKey, newNote, removeNote, restoreNote, togglePin } from './notes'
7import { drawPane, selectedRowKey } from './view'
8import type { PaneModel } from './view'
9
10// Every function that takes `$`, and every atom, lives in this file: the
11// engine follows `$` and state references only within the module's own file.
12
13const notes = atom({ plugin: 'jot', key: 'notes' } as const, [])
14const focusedId = atom({ plugin: 'jot', key: 'focusedId' } as const, null)
15const editingId = atom({ plugin: 'jot', key: 'editingId' } as const, null)
16const lastPrompt = atom({ plugin: 'jot', key: 'lastPrompt' } as const, null)
17const draftGen = atom({ plugin: 'jot', key: 'draftGen' } as const, 0)
18const ringOn = atom({ plugin: 'jot', key: 'ringOn' } as const, null)
19const listGen = atom({ plugin: 'jot', key: 'listGen' } as const, 0)
20const lastDeleted = atom({ plugin: 'jot', key: 'lastDeleted' } as const, null)
21const showKeys = atom({ plugin: 'jot', key: 'showKeys' } as const, false)
22
23const AGAIN = 'ctrl+x tab for another.'
24
25// The keys the ring can reach, in drawing order, as the last draw reported
26// them: what the focus hook steers by. Redrawn on every render, so a reload
27// losing it costs nothing.
28let reachable: string[] = []
29
30// The list window's first row as last drawn. A scroll draws every row under a
31// new key, and the engine's ring keeps a place, not a key, so a scroll
32// re-seats the ring on the selected note by its new key.
33let windowStart: number | null = null
34
35// The last model the pane was drawn from: what an action's selection is laid
36// out against to know the row key it will be drawn under.
37let lastModel: PaneModel | null = null
38
39/** Where a ring may rest: a note row or the new-note field; the rest have hotkeys. */
40function isRestingKey(key: string): boolean {
41 return key.startsWith('note-') || key.startsWith('draft-')
42}
43
44/**
45 * Where a move of the person's ring onto `target` should land instead: the
46 * nearest resting key past it in the direction of travel, or null to stay.
47 */
48function steer(from: string | null, target: string): string | null {
49 const to = reachable.indexOf(target)
50 const at = from === null ? -1 : reachable.indexOf(from)
51 const step = at >= 0 && to < at ? -1 : 1
52 for (let i = to + step; i >= 0 && i < reachable.length; i += step) {
53 if (isRestingKey(reachable[i]!)) return reachable[i]!
54 }
55
56 return null
57}
58
59// ── Store ──────────────────────────────────────────────────────────────────
60
61const STORE_KEY = 'notes'
62
63/** The notes this session starts with: the store's, unless state already holds some (a reload). */
64async function loadNotes($: EngineInterface): Promise<Note[]> {
65 const current = await read($, notes)
66 if (current.length > 0) {
67 return current
68 }
69 const kept = await $.store.get(STORE_KEY)
70 if (Array.isArray(kept) && kept.length > 0) {
71 await update($, notes, () => kept as Note[])
72
73 return kept as Note[]
74 }
75
76 return current
77}
78
79/**
80 * Applies `change` to the notes as the store holds them, not this session's
81 * copy, so a note another open session saved is never overwritten.
82 */
83async function saveNotes($: EngineInterface, change: (list: Note[]) => Note[]): Promise<Note[]> {
84 const kept = await $.store.get(STORE_KEY)
85 const base = Array.isArray(kept) ? (kept as Note[]) : await read($, notes)
86 const next = change(base)
87 await $.store.set(STORE_KEY, next)
88 await update($, notes, () => next)
89 await update($, listGen, n => n + 1)
90
91 return next
92}
93
94async function jot($: EngineInterface, text: string): Promise<Note | undefined> {
95 const trimmed = text.trim()
96 if (trimmed === '') {
97 return undefined
98 }
99 const note = newNote(trimmed, await $.clock.now())
100 await saveNotes($, list => addNote(list, note))
101
102 return note
103}
104
105// ── Focus ──────────────────────────────────────────────────────────────────
106
107const PANE = 'jot'
108const TITLE = 'Notes'
109
110/** Moves the pane's focus ring; best effort, so a refused move never sinks the action. */
111async function focusKey($: EngineInterface, key: string): Promise<void> {
112 try {
113 await $.ui.focus({ requestId: PANE, key })
114 } catch {
115 // The ring stays where it was; the person can still arrow to it.
116 }
117}
118
119/**
120 * Hands the keyboard back to the prompt without Esc: closing the pane returns
121 * the keys, and reopening it without `focus` keeps it on screen.
122 */
123async function returnToPrompt($: EngineInterface): Promise<void> {
124 await $.ui.close({ id: PANE })
125 await $.ui.open({ id: PANE, title: TITLE })
126}
127
128/** Opens the pane with the keyboard, reseating one left waiting undrawn. */
129async function openFocused($: EngineInterface): Promise<UiOpenResult> {
130 let opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
131 if (!opened.isPlaced) {
132 // The unasked open at session start can leave the pane waiting undrawn
133 // on a narrow terminal, and opening an open id only retitles it: close
134 // that instance so this asked open seats a fresh one.
135 await $.ui.close({ id: PANE })
136 opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
137 }
138
139 return opened
140}
141
142// ── Actions ────────────────────────────────────────────────────────────────
143
144/** What an action leaves for the pane to do: where the ring goes, what to say. */
145type Outcome = {
146 /** An element to move the ring to by key: the draft field. */
147 focus?: string
148 /** A note to select and move the ring to; its row key is worked out at apply. */
149 select?: string
150 toast?: string
151 toPrompt?: true
152}
153
154type ActionContext = { note?: Note; list: Note[]; surface: RenderSurface }
155
156async function grabSelection($: EngineInterface): Promise<string> {
157 const selected = await $.ui.selection()
158 if (selected === undefined || selected.text.trim() === '') {
159 return 'Nothing is selected. Select text with the mouse first (fullscreen terminal).'
160 }
161 await jot($, selected.text)
162
163 return 'Saved the selection as a note.'
164}
165
166async function paste($: EngineInterface, text: string, mode: 'insert' | 'replace'): Promise<Outcome> {
167 const filled = await $.prompt.fill({ text, mode })
168 if (!filled.isFilled) {
169 return { toast: `Could not paste${filled.refusal ? ` (${filled.refusal})` : ''}.` }
170 }
171
172 // The fill hands the keys to the prompt by itself; an Esc now would be the
173 // prompt's (and stops a running reply), so the toast never asks for one.
174 return { toast: mode === 'insert' ? `Pasted. ${AGAIN}` : `Prompt replaced. ${AGAIN}` }
175}
176
177/** The note the action buttons act on: the highlighted one, else the first. */
178async function target($: EngineInterface): Promise<Note | undefined> {
179 const list = await read($, notes)
180 const id = await read($, focusedId)
181
182 return list.find(note => note.id === id) ?? list[0]
183}
184
185/** Saves the draft field: an edit of the note being edited, else a new note. */
186async function submitDraft($: EngineInterface, value: string): Promise<Outcome> {
187 const text = value.trim()
188 const id = await read($, editingId)
189 await update($, editingId, () => null)
190 // Edit only a note that still exists; one deleted meanwhile makes this an add.
191 if (id !== null && (await read($, notes)).some(note => note.id === id)) {
192 if (text !== '') {
193 await saveNotes($, current => editNote(current, id, text))
194 }
195 await update($, draftGen, n => n + 1)
196
197 return { select: id }
198 }
199 if (text === '') {
200 return {}
201 }
202 await jot($, text)
203 const nextGen = await update($, draftGen, n => n + 1)
204
205 return { focus: `draft-${nextGen}` }
206}
207
208async function runAction($: EngineInterface, key: ActionKey, ctx: ActionContext): Promise<Outcome> {
209 if (key === 'grab') {
210 return { toast: await grabSelection($) }
211 }
212 if (key === 'last') {
213 const prompt = await read($, lastPrompt)
214 if (prompt === null) {
215 return { toast: 'No prompt sent yet this session.' }
216 }
217 await jot($, prompt)
218
219 return { toast: 'Saved the last prompt as a note.' }
220 }
221 if (key === 'prompt') {
222 return { toPrompt: true }
223 }
224 if (key === 'add') {
225 return { focus: `draft-${await read($, draftGen)}` }
226 }
227 if (key === 'keys') {
228 await update($, showKeys, shown => !shown)
229
230 return {}
231 }
232 if (key === 'undo') {
233 const gone = await read($, lastDeleted)
234 if (gone === null) {
235 return { toast: 'Nothing to undo.' }
236 }
237 await saveNotes($, current => restoreNote(current, gone))
238 await update($, lastDeleted, () => null)
239 await update($, focusedId, () => gone.id)
240
241 return { select: gone.id, toast: 'Restored.' }
242 }
243 const note = ctx.note
244 if (note === undefined) {
245 return { toast: 'No note to act on.' }
246 }
247 if (key === 'delete') {
248 const after = await saveNotes($, current => removeNote(current, note.id))
249 await update($, lastDeleted, () => note)
250 await update($, editingId, id => (id === note.id ? null : id))
251 const index = ctx.list.findIndex(one => one.id === note.id)
252 const neighbour = after[Math.min(index, after.length - 1)]
253 await update($, focusedId, () => neighbour?.id ?? null)
254 const toast = 'Deleted. u undoes it.'
255
256 return neighbour === undefined ? { toast } : { select: neighbour.id, toast }
257 }
258 if (key === 'edit') {
259 await update($, editingId, () => note.id)
260 const nextGen = await update($, draftGen, n => n + 1)
261
262 return { focus: `draft-${nextGen}` }
263 }
264 if (key === 'pin') {
265 await saveNotes($, current => togglePin(current, note.id))
266 await update($, focusedId, () => note.id)
267
268 // The re-sort draws new keys, so the focus call waits for that drawing
269 // instead of landing on the note's old place in this one.
270 return { select: note.id }
271 }
272 if (key === 'copy') {
273 const copied = await $.ui.copy({ text: note.text, surface: ctx.surface })
274
275 return { toast: copied.isCopied ? 'Copied to the clipboard.' : 'Could not copy.' }
276 }
277
278 return paste($, note.text, 'replace')
279}
280
281// ── Wiring ─────────────────────────────────────────────────────────────────
282
283/** Does what an action left for the pane: moves the ring, says its line, hands back the keys. */
284async function apply($: EngineInterface, outcome: Outcome): Promise<void> {
285 if (outcome.select !== undefined && lastModel !== null) {
286 const id = outcome.select
287 await update($, focusedId, () => id)
288 const model = { ...lastModel, list: await read($, notes), focused: id, listGen: await read($, listGen) }
289 outcome = { ...outcome, focus: selectedRowKey(model, id) }
290 }
291 if (outcome.focus !== undefined) {
292 const key = outcome.focus
293 await update($, ringOn, () => key)
294 await focusKey($, key)
295 }
296 if (outcome.toast !== undefined) {
297 $.ui.toast(outcome.toast)
298 }
299 if (outcome.toPrompt) {
300 await returnToPrompt($)
301 }
302}
303
304export const register: Register = on => {
305 on('session.start', async ($, e, next) => {
306 await loadNotes($)
307 await $.command.register({
308 name: 'notes',
309 description: 'Notes pane: /notes to pick a note, /notes <n> to paste note n',
310 argumentHint: '[n]',
311 })
312 await $.command.register({
313 name: 'note',
314 description: 'Save a note: /note <text>, or /note alone to save the mouse selection',
315 argumentHint: '[text]',
316 })
317 void $.ui.open({ id: PANE, title: TITLE })
318
319 return next(e)
320 })
321
322 on('command.run', { command: 'notes' }, async ($, e) => {
323 const arg = e.args.trim()
324 if (/^\d+$/.test(arg)) {
325 const n = Number(arg)
326 const note = (await read($, notes))[n - 1]
327 if (note === undefined) {
328 return { text: `No note ${n}.` }
329 }
330 const filled = await $.prompt.fill({ text: note.text, mode: 'insert' })
331
332 return { text: filled.isFilled ? `Pasted note ${n}.` : `Could not paste note ${n}.` }
333 }
334 const opened = await openFocused($)
335
336 return { text: opened.isPlaced ? 'Notes pane opened.' : `Notes pane is waiting to be shown: ${opened.reason}` }
337 })
338
339 on('command.run', { command: 'note' }, async ($, e) => {
340 if (e.args.trim() === '') {
341 return { text: await grabSelection($) }
342 }
343 await jot($, e.args)
344
345 return { text: 'Note saved.' }
346 })
347
348 on('prompt.submit', async ($, e, next) => {
349 const text = e.text.trim()
350 if (text !== '' && !text.startsWith('/')) {
351 await update($, lastPrompt, () => text)
352 }
353
354 return next(e)
355 })
356
357 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
358 // Arrows and Tab walk every drawn button; keep the person's ring on the
359 // field and the notes, so ↓ past the last note stays there instead of
360 // wandering into the key rows (each of which has a hotkey anyway).
361 if (e.origin.kind === 'person' && e.element !== undefined && !isRestingKey(e.element) && reachable.includes(e.element)) {
362 const landing = steer(await read($, ringOn), e.element)
363 if (landing !== null) {
364 // The engine keeps a person's move where the keys sent it, whatever a
365 // hook rewrites, so refuse this one and make the move ourselves once
366 // this event has settled. Our own move skips this hook: record it here.
367 await update($, ringOn, () => landing)
368 const id = idOfRowKey(landing)
369 if (id !== null) {
370 await update($, focusedId, () => id)
371 }
372 $.clock.after(0, () => void focusKey($, landing))
373 }
374
375 return { deny: 'the ring stays on the notes' }
376 }
377 const element = e.element ?? null
378 await update($, ringOn, () => element)
379 const id = idOfRowKey(element)
380 if (id !== null) {
381 await update($, focusedId, () => id)
382 }
383
384 return next(e)
385 })
386
387 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
388 const list = await read($, notes)
389 const editId = await read($, editingId)
390 const model: PaneModel = {
391 list,
392 focused: await read($, focusedId),
393 editing: list.find(note => note.id === editId),
394 gen: await read($, draftGen),
395 listGen: await read($, listGen),
396 ring: await read($, ringOn),
397 cols: e.props.bodyColumns,
398 bodyRows: e.props.scroll.bodyRows,
399 isFocused: e.props.isFocused,
400 showKeys: await read($, showKeys),
401 now: await $.clock.now(),
402 surface: e.surface,
403 }
404 const kit = $.ui.resolve(e)
405 lastModel = model
406
407 return drawPane(kit, model, {
408 press: key => {
409 void (async () => {
410 // A key pressed from the key list does its job and closes the list.
411 if (key !== 'keys') await update($, showKeys, () => false)
412 const note = await target($)
413 await apply($, await runAction($, key, { note, list: await read($, notes), surface: e.surface }))
414 })()
415 },
416 submit: value => {
417 void submitDraft($, value).then(outcome => apply($, outcome))
418 },
419 paste: note => {
420 void paste($, note.text, 'insert').then(outcome => apply($, outcome))
421 },
422 order: (keys, start) => {
423 reachable = keys
424 const scrolled = windowStart !== null && start !== windowStart
425 windowStart = start
426 const selected = keys.find(key => idOfRowKey(key) === model.focused)
427 if (scrolled && e.props.isFocused && selected !== undefined && model.ring?.startsWith('note-')) {
428 $.clock.after(0, () => {
429 void (async () => {
430 await update($, ringOn, () => selected)
431 await focusKey($, selected)
432 })()
433 })
434 }
435 },
436 })
437 })
438}
439hooks/keys.ts 45 lines1import type { Note } from '../types'
2
3export type ActionKey =
4 | 'edit'
5 | 'pin'
6 | 'delete'
7 | 'copy'
8 | 'replace'
9 | 'undo'
10 | 'add'
11 | 'grab'
12 | 'last'
13 | 'prompt'
14 | 'keys'
15
16/** `note` keys act on the highlighted note; `pane` keys work from anywhere in the pane. */
17export type KeyScope = 'note' | 'pane'
18
19export type KeySpec = {
20 key: ActionKey
21 hotkey: string
22 label: string | ((note: Note) => string)
23 scope: KeyScope
24 /** What the key list says it does. */
25 help: string
26}
27
28export const KEYS: readonly KeySpec[] = [
29 { key: 'edit', hotkey: 'e', label: 'edit', scope: 'note', help: 'Edit the note' },
30 { key: 'pin', hotkey: 'p', label: note => (note.isPinned ? 'unpin' : 'pin'), scope: 'note', help: 'Pin it to the top, or unpin it' },
31 { key: 'delete', hotkey: 'd', label: 'delete', scope: 'note', help: 'Delete it (u undoes)' },
32 { key: 'copy', hotkey: 'c', label: 'copy', scope: 'note', help: 'Copy it to the clipboard' },
33 { key: 'replace', hotkey: 'r', label: 'replace', scope: 'note', help: 'Replace the whole prompt with it' },
34 { key: 'prompt', hotkey: 'q', label: 'prompt', scope: 'pane', help: 'Back to the prompt' },
35 { key: 'keys', hotkey: 'k', label: 'keys', scope: 'pane', help: 'Show or hide this list' },
36 { key: 'undo', hotkey: 'u', label: 'undo', scope: 'pane', help: 'Bring back the last deleted note' },
37 { key: 'add', hotkey: 'a', label: 'new', scope: 'pane', help: 'Write a new note' },
38 { key: 'grab', hotkey: 'g', label: 'grab', scope: 'pane', help: 'Save the mouse selection as a note' },
39 { key: 'last', hotkey: 'l', label: 'last', scope: 'pane', help: 'Save your last prompt as a note' },
40]
41
42export function labelOf(spec: KeySpec, note: Note | undefined): string {
43 return typeof spec.label === 'string' ? spec.label : note === undefined ? spec.key : spec.label(note)
44}
45hooks/notes.ts 150 lines1import type { Note } from '../types'
2
3export function newNote(text: string, now: number): Note {
4 const id = now.toString(36) + Math.random().toString(36).slice(2, 8)
5
6 return { id, text, isPinned: false, createdAt: now }
7}
8
9export function addNote(list: readonly Note[], note: Note): Note[] {
10 // In front, so the stable sort keeps it first among same-millisecond notes.
11 return sortNotes([note, ...list])
12}
13
14export function removeNote(list: readonly Note[], id: string): Note[] {
15 return list.filter(note => note.id !== id)
16}
17
18export function editNote(list: readonly Note[], id: string, text: string): Note[] {
19 return list.map(note => (note.id === id ? { ...note, text } : note))
20}
21
22export function togglePin(list: readonly Note[], id: string): Note[] {
23 return sortNotes(list.map(note => (note.id === id ? { ...note, isPinned: !note.isPinned } : note)))
24}
25
26/** Pinned first, then newest first. */
27export function sortNotes(list: readonly Note[]): Note[] {
28 return [...list].sort((a, b) =>
29 a.isPinned === b.isPinned ? b.createdAt - a.createdAt : a.isPinned ? -1 : 1,
30 )
31}
32
33/** A collapsed row's label: the first line as plain text, `…` when more follows, cut to `columns`. */
34export function labelFor(note: Note, columns: number): string {
35 const more = note.text.trim().includes('\n')
36 const label = plainLabel(note.text) + (more ? ' …' : '')
37 const room = Math.max(4, columns)
38
39 return label.length > room ? label.slice(0, room - 1) + '…' : label
40}
41
42/** Puts a deleted note back in its sorted place; a note already there stays once. */
43export function restoreNote(list: readonly Note[], note: Note): Note[] {
44 return list.some(one => one.id === note.id) ? [...list] : sortNotes([note, ...list])
45}
46
47const MINUTE = 60_000
48const HOUR = 60 * MINUTE
49const DAY = 24 * HOUR
50
51/** How old a note is, in the fewest characters: `now`, `5m`, `2h`, `3d`, `4w`, `1y`. */
52export function ageLabel(now: number, createdAt: number): string {
53 const age = Math.max(0, now - createdAt)
54 if (age < MINUTE) return 'now'
55 if (age < HOUR) return `${Math.floor(age / MINUTE)}m`
56 if (age < DAY) return `${Math.floor(age / HOUR)}h`
57 if (age < 7 * DAY) return `${Math.floor(age / DAY)}d`
58 if (age < 365 * DAY) return `${Math.floor(age / (7 * DAY))}w`
59
60 return `${Math.floor(age / (365 * DAY))}y`
61}
62
63/** The text a focused note shows beneath its row: everything after the first line. */
64export function previewText(text: string): string {
65 const at = text.trim().indexOf('\n')
66
67 return at < 0 ? '' : text.trim().slice(at + 1).trim()
68}
69
70/** A note's first line with its inline Markdown taken out, for a one-line row. */
71export function plainLabel(text: string): string {
72 const first = (text.trim().split('\n')[0] ?? '').trim()
73
74 return first
75 .replace(/^(#{1,6}|[-*+]|>|\d+[.)])\s+/, '')
76 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
77 .replace(/(\*\*|__|\*|_|`|~~)(.+?)\1/g, '$2')
78 .trim()
79}
80
81export type Layout = {
82 start: number
83 end: number
84 above: number
85 below: number
86 /** Rows of the focused note's preview. */
87 preview: number
88 /** Whether the pinned rules are drawn; dropped in a pane too short for them. */
89 rules: boolean
90}
91
92/** Rule rows a stretch of the list draws: one over the pinned notes, one under them. */
93function ruleRows(list: readonly Note[], start: number, end: number): number {
94 const drawn = list.slice(start, end)
95 const pinned = drawn.some(note => note.isPinned)
96
97 return pinned ? (drawn.some(note => !note.isPinned) ? 2 : 1) : 0
98}
99
100/**
101 * Which notes to draw in `room` rows, counting the focused note's preview and
102 * its row of keys, the pinned rules and the `n more` lines, so the tree never
103 * outgrows the pane and the arrows keep walking. The focused note stays
104 * centred with a drawn note on each side; a short pane gives up the preview
105 * first, then the rules.
106 */
107export function layoutRows(
108 list: readonly Note[],
109 focusedId: string | null,
110 room: number,
111 previewRows: number,
112): Layout {
113 const count = list.length
114 const index = Math.max(0, list.findIndex(note => note.id === focusedId))
115 const most = Math.min(previewRows, Math.max(0, Math.floor((room - 5) / 2)))
116 for (const rules of [true, false]) {
117 for (let preview = most; preview >= 0; preview--) {
118 const fixed = preview + 1
119 const rulesIn = (start: number, end: number) => (rules ? ruleRows(list, start, end) : 0)
120 if (count + fixed + rulesIn(0, count) <= room) {
121 return { start: 0, end: count, above: 0, below: 0, preview, rules }
122 }
123 for (let shown = count - 1; shown >= 3; shown--) {
124 const start = Math.min(Math.max(0, index - Math.floor(shown / 2)), count - shown)
125 const end = start + shown
126 const more = (start > 0 ? 1 : 0) + (end < count ? 1 : 0)
127 if (shown + fixed + rulesIn(start, end) + more <= room) {
128 return { start, end, above: start, below: count - end, preview, rules }
129 }
130 }
131 }
132 }
133 const start = Math.min(index, Math.max(0, count - 1))
134
135 return { start, end: Math.min(count, start + 1), above: start, below: Math.max(0, count - start - 1), preview: 0, rules: false }
136}
137
138/** A note row's key: the list's generation in it, so a re-sorted list draws new keys. */
139export function rowKey(id: string, gen: number | string): string {
140 return `note-${id}-${gen}`
141}
142
143/** The note id in a row key, or null for any other element. */
144export function idOfRowKey(key: string | null): string | null {
145 if (key === null || !key.startsWith('note-')) return null
146 const end = key.lastIndexOf('-')
147
148 return end > 'note-'.length ? key.slice('note-'.length, end) : key.slice('note-'.length)
149}
150hooks/view.tsx 307 lines1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { Note } from '../types'
4import { KEYS, labelOf } from './keys'
5import type { ActionKey, KeySpec } from './keys'
6import { ageLabel, labelFor, layoutRows, previewText, rowKey } from './notes'
7
8type TerminalKit = Elements['terminal']
9
10export type Kit = Pick<TerminalKit, 'Box' | 'Text' | 'Button' | 'Markdown'> & { Input?: TerminalKit['Input'] }
11
12export type PaneModel = {
13 list: Note[]
14 focused: string | null
15 editing: Note | undefined
16 gen: number
17 listGen: number
18 ring: string | null
19 cols: number
20 bodyRows: number
21 isFocused: boolean
22 showKeys: boolean
23 now: number
24 surface: RenderSurface
25}
26
27export type PaneHandlers = {
28 press: (key: ActionKey) => void
29 submit: (value: string) => void
30 paste: (note: Note) => void
31 /** The keys the ring can reach in drawing order, and the list window's first row, reported on every draw. */
32 order: (keys: string[], start: number) => void
33}
34
35/** Rows the focused note's Markdown may take below its row. */
36const PREVIEW_ROWS = 4
37/** The gutter left of every row: the selection bar and a space. */
38const GUTTER = 2
39// Below this many body rows the key rows shrink to one, q, k and u first, and
40// k shows every key; from it, every key has its button on screen.
41const SHORT_PANE = 10
42
43/** Cells kept free at a key row's end: the engine's drawing of a row runs a little wider than its labels. */
44const KEY_ROW_SLACK = 4
45
46/** Cells a plain hotkey Button takes: `e: edit`. */
47function keyWidth(spec: KeySpec, note: Note | undefined): number {
48 return spec.hotkey.length + 2 + labelOf(spec, note).length
49}
50
51function rule(Text: Kit['Text'], label: string, cols: number, key: string) {
52 const head = label === '' ? '' : `── ${label} `
53
54 return (
55 <Text key={key} dimColor wrap="truncate">
56 {head + '─'.repeat(Math.max(0, cols - head.length))}
57 </Text>
58 )
59}
60
61export function drawPane(kit: Kit, model: PaneModel, on: PaneHandlers) {
62 const { Box, Text, Button, Markdown, Input } = kit
63 const { list, focused, editing, gen, ring, cols, isFocused } = model
64 // With no notes the ring starts on the draft field, so treat that as typing too.
65 const isTyping = ring === null ? list.length === 0 : ring.startsWith('draft-')
66 const armed = isFocused && !isTyping && model.surface !== 'mobile'
67 const hot = (key: string) => (armed ? key : undefined)
68 const focusedNote = list.find(note => note.id === focused) ?? list[0]
69
70 // The engine's ring keeps a place in the drawing, not a key, so everything it
71 // can reach sits where a change of selection or focus never moves it: the
72 // field, then the notes, then the key rows. Nothing focusable above the notes.
73 const reachable: string[] = []
74 const button = (spec: KeySpec) => (
75 <Button
76 key={spec.key}
77 label={labelOf(spec, focusedNote)}
78 plain
79 dimColor
80 hotkey={hot(spec.hotkey)}
81 onPress={() => on.press(spec.key)}
82 />
83 )
84
85 // Where the keys are, said first: the one thing v0.1 never showed.
86 const status = isFocused ? (
87 <Box key="status" flexDirection="row" justifyContent="space-between">
88 <Text>
89 <Text color="claude">●</Text> keys here
90 </Text>
91 <Text dimColor wrap="truncate">↑↓ move ⏎ paste</Text>
92 </Box>
93 ) : (
94 <Text key="status" dimColor wrap="truncate">
95 ○ ctrl+x tab or /notes to use
96 </Text>
97 )
98
99 // Mobile draws no Input: its pane lists and pastes, and /note adds.
100 const draft =
101 Input === undefined || !hasDraft(model) ? null : (
102 <Input
103 key={`draft-${gen}`}
104 placeholder={editing ? 'Edit the note (empty cancels)' : 'New note… Enter saves'}
105 value={editing?.text ?? ''}
106 submitLabel={editing ? 'update' : 'save'}
107 autoFocus={list.length === 0 ? true : undefined}
108 onSubmit={(value: string) => on.submit(value)}
109 />
110 )
111 if (draft !== null) reachable.push(`draft-${gen}`)
112
113 const { keyRows, room } = frame(model)
114 const keyRowsDrawn = keyRows.map((row, i) => (
115 <Box key={`keys-${i}`} flexDirection="row" gap={2}>
116 {row.map(button)}
117 </Box>
118 ))
119 const keyRowKeys = keyRows.flat().map(spec => spec.key)
120
121 if (model.showKeys || list.length === 0) {
122 on.order([...reachable, ...keyRowKeys], 0)
123 const lines = [
124 ['↑ ↓', 'Move between notes'],
125 ['Enter', 'Paste the note at the cursor'],
126 ['1–9', 'Paste note 1 to 9'],
127 ...KEYS.map(spec => [spec.hotkey, spec.help]),
128 ]
129
130 return (
131 <Box flexDirection="column">
132 {status}
133 {draft}
134 {list.length === 0 && !model.showKeys ? (
135 <Text dimColor wrap="wrap">
136 No notes yet. Type one above and press Enter, or run /note <text>.
137 </Text>
138 ) : (
139 lines.slice(0, Math.max(0, room - 1)).map(([key, help]) => (
140 <Text key={`help-${key}`} wrap="truncate">
141 <Text color="claude">{(key ?? '').padEnd(6)}</Text>
142 {help}
143 </Text>
144 ))
145 )}
146 {keyRowsDrawn}
147 </Box>
148 )
149 }
150
151 const layout = layoutRows(list, focusedNote?.id ?? null, room, previewBudget(model, focusedNote?.id ?? null))
152 const window = windowTag(model, layout.start)
153 const rows: RenderChildren[] = []
154 if (layout.above > 0) rows.push(<Text key="above" dimColor>{' '}↑ {layout.above} more</Text>)
155 for (let i = layout.start; i < layout.end; i++) {
156 const note = list[i]!
157 const prev = i > layout.start ? list[i - 1] : undefined
158 if (layout.rules && note.isPinned && prev?.isPinned !== true) rows.push(rule(Text, 'pinned', cols, 'rule-pinned'))
159 if (layout.rules && !note.isPinned && prev?.isPinned === true) rows.push(rule(Text, '', cols, 'rule-rest'))
160
161 const isSelected = note.id === focusedNote?.id
162 const age = ageLabel(model.now, note.createdAt)
163 const digit = i < 9 ? 3 : 0
164 const key = rowKey(note.id, window)
165 reachable.push(key)
166 rows.push(
167 <Box key={`row-${note.id}`} flexDirection="row">
168 <Box width={GUTTER} flexShrink={0}>
169 {isSelected ? <Text color={isFocused ? 'claude' : 'inactive'}>{isFocused ? '▌' : '▏'}</Text> : <Text> </Text>}
170 </Box>
171 <Box flexGrow={1}>
172 <Button
173 key={key}
174 label={labelFor(note, cols - GUTTER - digit - age.length - 2)}
175 plain
176 hotkey={i < 9 ? hot(String(i + 1)) : undefined}
177 autoFocus={isSelected ? true : undefined}
178 onPress={() => on.paste(note)}
179 />
180 </Box>
181 <Text color="subtle"> {age}</Text>
182 </Box>,
183 )
184
185 // The lifted card: the rest of the note, clipped to its budget. Not
186 // focusable, so it moves nothing the ring keeps a place on.
187 const body = isSelected && isFocused ? previewText(note.text) : ''
188 if (body !== '' && layout.preview > 0) {
189 rows.push(
190 <Box
191 key="preview"
192 height={layout.preview}
193 overflow="hidden"
194 marginLeft={GUTTER}
195 borderStyle="quote"
196 borderColor="claude"
197 paddingLeft={1}
198 >
199 <Markdown text={body} />
200 </Box>,
201 )
202 }
203 }
204 if (layout.below > 0) rows.push(<Text key="below" dimColor>{' '}↓ {layout.below} more</Text>)
205 on.order([...reachable, ...keyRowKeys], layout.start)
206
207 return (
208 <Box flexDirection="column">
209 {status}
210 {draft}
211 {rows}
212 {keyRowsDrawn}
213 </Box>
214 )
215}
216
217/** Whether the draft field is drawn: everywhere but mobile. */
218function hasDraft(model: PaneModel): boolean {
219 return model.surface !== 'mobile'
220}
221
222/** Packs `specs`, in order, into as few rows of `columns` as they need. */
223function packKeys(specs: KeySpec[], note: Note | undefined, columns: number): KeySpec[][] {
224 const rows: KeySpec[][] = []
225 let used = 0
226 for (const spec of specs) {
227 const width = keyWidth(spec, note)
228 const row = rows[rows.length - 1]
229 if (row !== undefined && used + 2 + width <= columns) {
230 row.push(spec)
231 used += 2 + width
232 } else {
233 rows.push([spec])
234 used = width
235 }
236 }
237
238 return rows
239}
240
241/** The pane keys with q and k, the way out and the key list, first. */
242function paneKeysFirst(): KeySpec[] {
243 const pane = KEYS.filter(spec => spec.scope === 'pane')
244 const lead = pane.filter(spec => spec.key === 'prompt' || spec.key === 'keys' || spec.key === 'undo')
245
246 return [...lead, ...pane.filter(spec => !lead.includes(spec))]
247}
248
249/**
250 * The pane's fixed parts: its key rows (last in the drawing, every key on one
251 * of them; in a short pane one row of q, k and u, the rest a k away) and the
252 * rows left for the list.
253 */
254function frame(model: PaneModel): { keyRows: KeySpec[][]; room: number } {
255 const { list } = model
256 const focusedNote = list.find(note => note.id === model.focused) ?? list[0]
257 const width = model.cols - KEY_ROW_SLACK
258 const noteKeys = list.length === 0 ? [] : KEYS.filter(spec => spec.scope === 'note')
259 const paneKeys = paneKeysFirst()
260 const keyRows: KeySpec[][] = !model.isFocused
261 ? []
262 : model.showKeys || model.bodyRows >= SHORT_PANE
263 ? [...packKeys(noteKeys, focusedNote, width), ...packKeys(paneKeys, focusedNote, width)]
264 : [fitList([...paneKeys.slice(0, 3), ...noteKeys, ...paneKeys.slice(3)], focusedNote, width)]
265 // layoutRows counts one key row itself; the rest are fixed.
266 const fixedRows = 1 + (hasDraft(model) ? 1 : 0) + Math.max(0, keyRows.length - 1)
267
268 return { keyRows, room: Math.max(1, model.bodyRows - fixedRows) }
269}
270
271/**
272 * The list's generation and window in every row key: a re-sort or a scroll
273 * redraws every row under a new key, so the ring (which the engine keeps by
274 * place, not key) is re-seated by key, and a focus call waits for the drawing.
275 */
276function windowTag(model: PaneModel, start: number): string {
277 return `${model.listGen}.${start}`
278}
279
280/** Preview rows to budget for the selected note: none when it has nothing below its first line. */
281function previewBudget(model: PaneModel, id: string | null): number {
282 const note = model.list.find(one => one.id === id)
283
284 return model.isFocused && note !== undefined && previewText(note.text) !== '' ? PREVIEW_ROWS : 0
285}
286
287/** The key note `id`'s row will have when `model` is drawn with it selected. */
288export function selectedRowKey(model: PaneModel, id: string): string {
289 const layout = layoutRows(model.list, id, frame({ ...model, focused: id }).room, previewBudget(model, id))
290
291 return rowKey(id, windowTag(model, layout.start))
292}
293
294/** The specs, in order, that fit one row of `columns`. */
295function fitList(specs: KeySpec[], note: Note | undefined, columns: number): KeySpec[] {
296 const out: KeySpec[] = []
297 let used = 0
298 for (const spec of specs) {
299 const width = keyWidth(spec, note) + (out.length > 0 ? 2 : 0)
300 if (used + width > columns) break
301 out.push(spec)
302 used += width
303 }
304
305 return out
306}
307types/index.d.ts 19 lines1export type Note = { id: string; text: string; isPinned: boolean; createdAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 jot: {
6 notes: Note[]
7 focusedId: string | null
8 editingId: string | null
9 lastPrompt: string | null
10 draftGen: number
11 ringOn: string | null
12 /** Bumped on every write to the list, so row keys change and a focus call waits for the new drawing. */
13 listGen: number
14 lastDeleted: Note | null
15 showKeys: boolean
16 }
17 }
18}
19