SLOPSHOPPER

jot

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.

newpanecommandtoastprompttimer
v0.2.1MITupdated 2026-10-07Danh295/dannys-claude-mods/plugins/jot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jot
│ ┃ Notes ✕ › fix the failing auth test and add an audit log call │ ┃ ○ ctrl+x tab or /notes to use │ ┃ New note… Enter saves ⏎ save ⏺ Read(src/auth.ts) │ ┃ No notes yet. Type one above and press ⎿ Read 6 lines │ ┃ Enter, or run /note <text>. ⏺ 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 │ │ › /notes │ ⎿ jot: Notes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notes
○ ctrl+x tab or /notes to use New note… Enter saves ⏎ save No notes yet. Type one above and press Enter, or run /note <text>.
README

jot

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.

Getting in and out

  • /notes opens the pane with the keyboard (from an empty prompt).
  • ctrl+x tab moves the keyboard into the pane, even when the prompt has text in it.
  • The pane's top line says where your keys are: ● keys here, or ○ ctrl+x tab or /notes to use.
  • Pasting a note returns the keyboard to the prompt by itself. q also goes back. You never need Esc (an Esc at the prompt stops a running reply).

Keys

KeyDoes
↑ ↓Move between notes
EnterPaste the selected note at the cursor
1–9Paste note 1 to 9
eEdit the selected note
pPin it to the top, or unpin it
dDelete it (u undoes)
uBring back the last deleted note
cCopy it to the clipboard
rReplace the whole prompt with it
aWrite a new note
gSave the mouse selection as a note (fullscreen)
lSave your last prompt as a note
qBack to the prompt
kShow or hide the key list

The selected note opens to show the rest of its text, rendered as Markdown.

Commands

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

Source 5 files
hooks/register.tsx 439 lines
1import { 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}
439
hooks/keys.ts 45 lines
1import 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}
45
hooks/notes.ts 150 lines
1import 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}
150
hooks/view.tsx 307 lines
1import 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 &lt;text&gt;.
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}
307
types/index.d.ts 19 lines
1export 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