SLOPSHOPPER

instantnotes

Your InstantNotes in a pane beside Claude Code: search as you type, read a note as Markdown, write one in the pane or with /note.

newpanecommandtoastprocesstimer
★ 1v0.2.0MITupdated 2026-10-02jamubc/toolbox/plugins/instantnotes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · instantnotes
│ ┃ InstantNotes ✕ › fix the failing auth test and add an audit log call │ ┃ ✎ InstantNotes via MCP server + new note rec │ ┃ ⌕ : words or a title · Enter searches · empt ⏺ Read(src/auth.ts) │ ┃ Recent notes · 0 ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ No notes yet. Press + new note to write one. ⏺ 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 │ ⎿ instantnotes: InstantNotes pane opened (through the instantnotes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · InstantNotes
✎ InstantNotes via MCP server + new note recent ⌕ : words or a title · Enter searches · empty lists recent n Recent notes · 0 ──────────────────────────────────────────────────────── No notes yet. Press + new note to write one.
README

instantnotes

Your InstantNotes library, directly in Claude Code. Search, read and capture notes without leaving the terminal.

The InstantNotes pane in Claude Code after /notes: a search field above four recent notes (Weekend pancakes, Grocery list, Trip ideas, Book notes), each with a one-line excerpt

Every note you have, one command away.

Install

Add the marketplace:

/plugin marketplace add jamubc/toolbox

Install the plugin:

/plugin install instantnotes@toolbox

Usage

CommandWhat it does
/notesOpens a pane with your recent notes.
/notes <words>Opens the pane searching for those words or a title.
/note <text>Saves the text as a new note. The first line becomes the title.

In the pane:

  • Type to filter the notes already listed; Enter searches the whole library. An empty search lists recent notes.
  • A note opens as Markdown with its spaces, tags and when it last changed. b goes back.
  • mention in prompt (u) puts a reference to the note in your prompt, so Claude can read it through the InstantNotes MCP server. paste into prompt (p) puts the note's text itself there. copy (c) copies it.
  • + new note opens a field: write the note and press Enter. It opens once saved.
  • Notes you have opened are kept for a minute, so going back and forth is instant; reload (r) reads one again.

How it reaches InstantNotes

The plugin uses what InstantNotes › Settings › Agents sets up, in this order:

  1. The instantnotes MCP server Claude Code already runs, once you ran the claude mcp add instantnotes … command from that page. This is the fast path: the server stays up, and every request is a call to it.
  2. instantnotes mcp run by the plugin, one request at a time. It finds the program from what claude mcp add wrote in ~/.claude.json or the project's .mcp.json (the exact program and library you use), from /Applications/InstantNotes.app or ~/Applications/InstantNotes.app, or on your PATH.

When none of these can start, the pane says so and lists the steps.

  • Needs InstantNotes 0.9.0 or later, with Access turned on in Settings › Agents.
  • /config → InstantNotes program names the program when the plugin cannot find it. /config → Notes library names a library other than the one the app or claude mcp add uses; the attachments folder is read beside it.
  • With nothing set, the app's own library is used: ~/.local/share/com.instantnotes.app on Linux, ~/Library/Application Support/com.instantnotes.app on macOS.
  • hooks/register.tsx draws the pane and answers /notes and /note.
  • hooks/library.ts finds the program and library from your configuration, and shapes text for the pane.
  • Each request is one MCP tools/call, through the connected server or one instantnotes mcp process, so the plugin follows the same rules as any agent.
  • /clear and /compact leave the search and the open note in the pane.
  • Notes reach Claude only when you put one in your prompt (mention or paste) and send it.
  • It writes only when you save a note (/note, or the field in the pane).
  • It talks only to the local instantnotes program or the MCP server you connected, never the network. ~/.claude.json is read only to find that server's command line.

Development

claude --plugin-dir plugins/instantnotes   # load it; edits hot-reload
claude plugin validate plugins/instantnotes
claude plugin test plugins/instantnotes
Source 3 files
hooks/register.tsx 542 lines
1// InstantNotes in a pane: search, read, capture. It reaches the library the
2// way the app's Agents page sets Claude Code up to: through the `instantnotes`
3// MCP server Claude Code already runs when the person added it with
4// `claude mcp add instantnotes …`, and otherwise by running `instantnotes mcp`
5// itself, found from that same configuration, the app bundle, or PATH.
6
7import { atom, derive, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Hit, OpenNote, Problem, Source } from '../types'
11import {
12  APP_BINARY, SETUP_STEPS, ago, argvFor, attachmentsBeside, clip, clipBody, defaultLibraries, filterHits, isNotFound,
13  launchFromConfig, oneLine, titleOf,
14} from './library'
15import type { Launch } from './library'
16
17const PANE = 'instantnotes'
18const SERVER = 'instantnotes'
19const LIST_LIMIT = 20
20const MARKDOWN_MAX = 9000
21const PASTE_MAX = 6000
22const CACHE_MS = 60_000
23const PROTOCOL = '2026-07-28'
24const NARROW = 56
25const ROW_BG = '#1c2730'
26
27const query = atom({ plugin: 'instantnotes', key: 'query' } as const, '')
28const typed = atom({ plugin: 'instantnotes', key: 'typed' } as const, '')
29const hits = atom({ plugin: 'instantnotes', key: 'hits' } as const, [])
30const note = atom({ plugin: 'instantnotes', key: 'note' } as const, null)
31const problem = atom({ plugin: 'instantnotes', key: 'problem' } as const, null)
32const isBusy = atom({ plugin: 'instantnotes', key: 'isBusy' } as const, false)
33const isComposing = atom({ plugin: 'instantnotes', key: 'isComposing' } as const, false)
34const source = atom({ plugin: 'instantnotes', key: 'source' } as const, null)
35const seeded = atom({ plugin: 'instantnotes', key: 'seeded' } as const, false)
36
37// /clear ends the session but not the process: the host's `$.state` starts over empty while this
38// module and its pane live on, so read straight from the host the search and the open note are
39// suddenly gone. `seeded` is false exactly then, and before the first `session.start`. `snapshot` is
40// what the pane draws from, read in one go: the host's while `seeded`, else what this process
41// last saw; `write` puts the kept values back before the first change after a /clear, and a
42// render that finds `seeded` false schedules that.
43type Snapshot = {
44  query: string
45  typed: string
46  hits: Hit[]
47  note: OpenNote | null
48  problem: Problem | null
49  isBusy: boolean
50  isComposing: boolean
51  source: Source
52}
53const KEYS = ['query', 'typed', 'hits', 'note', 'problem', 'isBusy', 'isComposing', 'source'] as const
54let kept: Snapshot = { query: '', typed: '', hits: [], note: null, problem: null, isBusy: false, isComposing: false, source: null }
55let wasLive: boolean | null = null // what the last read saw; null before the first
56const snapshot = derive(
57  [seeded, query, typed, hits, note, problem, isBusy, isComposing, source],
58  (isLive, query, typed, hits, note, problem, isBusy, isComposing, source): Snapshot => {
59    wasLive = isLive
60    if (!isLive) return kept
61    kept = { query, typed, hits, note, problem, isBusy, isComposing, source }
62    return kept
63  },
64)
65
66/** The one place the atoms are written: each key to its own, as the validator asks. */
67async function put<K extends keyof Snapshot>($: EngineInterface, key: K, value: Snapshot[K]): Promise<void> {
68  switch (key) {
69    case 'query': await update($, query, () => value as Snapshot['query']); break
70    case 'typed': await update($, typed, () => value as Snapshot['typed']); break
71    case 'hits': await update($, hits, () => value as Snapshot['hits']); break
72    case 'note': await update($, note, () => value as Snapshot['note']); break
73    case 'problem': await update($, problem, () => value as Snapshot['problem']); break
74    case 'isBusy': await update($, isBusy, () => value as Snapshot['isBusy']); break
75    case 'isComposing': await update($, isComposing, () => value as Snapshot['isComposing']); break
76    case 'source': await update($, source, () => value as Snapshot['source']); break
77  }
78}
79
80/** After a /clear (or at the first start), writes what this process kept back to the host. */
81let reseeding: Promise<void> | null = null
82function reseed($: EngineInterface): Promise<void> {
83  reseeding ??= (async () => {
84    try {
85      if (await read($, seeded)) return
86      for (const key of KEYS) await put($, key, kept[key])
87      await update($, seeded, () => true)
88      wasLive = true
89    } finally {
90      reseeding = null
91    }
92  })()
93  return reseeding
94}
95
96/** Changes one value from what `snapshot` reads, and keeps it here too. */
97async function write<K extends keyof Snapshot>($: EngineInterface, key: K, change: (now: Snapshot[K]) => Snapshot[K]): Promise<void> {
98  // `kept` is current once a read has seen the host live: only this module writes these values.
99  if (wasLive === null) await read($, snapshot)
100  if (!wasLive) await reseed($)
101  const next = change(kept[key])
102  kept = { ...kept, [key]: next }
103  await put($, key, next)
104}
105
106type Options = { binary?: string; db?: string }
107
108// What only this process has: notes already read, so a second look is
109// instant, and whether the MCP server answered this session.
110let home = ''
111let cwd = ''
112let mcpIsDown = false
113const notes = new Map<string, { note: OpenNote; at: number }>()
114let recent: { hits: Hit[]; at: number } | null = null
115
116const reasonOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
117
118// ---------------------------------------------------------------- reaching InstantNotes
119
120/** The program and library to run, from what the person already set up. */
121async function findLaunch($: EngineInterface, options: Options): Promise<Launch> {
122  const chosen = (options.binary ?? '').trim()
123  const candidates: Launch[] = []
124  if (chosen !== '' && chosen !== 'instantnotes') candidates.push({ binary: chosen })
125  // What `claude mcp add instantnotes …` wrote: the exact program and library the app named.
126  for (const file of [`${home}/.claude.json`, `${cwd}/.mcp.json`]) {
127    try {
128      const found = launchFromConfig(await $.fs.read(file), cwd)
129      if (found !== null) candidates.push(found)
130    } catch {}
131  }
132  candidates.push({ binary: `/Applications/${APP_BINARY}` }, { binary: `${home}/Applications/${APP_BINARY}` })
133  for (const candidate of candidates) {
134    if (candidate.binary.startsWith('/')) {
135      try {
136        if (!(await $.fs.exists(candidate.binary))) continue
137      } catch {
138        continue
139      }
140    }
141    return candidate
142  }
143  return { binary: chosen || 'instantnotes' }
144}
145
146async function defaultDb($: EngineInterface): Promise<string> {
147  const { linux, mac } = defaultLibraries(home)
148  try {
149    await $.fs.stat(linux)
150    return linux
151  } catch {
152    return mac
153  }
154}
155
156async function processSource($: EngineInterface, options: Options): Promise<Source> {
157  const launch = await findLaunch($, options)
158  const db = options.db || launch.db || (await defaultDb($))
159  const attachments = launch.attachments && !options.db ? launch.attachments : attachmentsBeside(db)
160  return { kind: 'process', argv: argvFor(launch.binary, db, attachments) }
161}
162
163function resultOf(reply: { isError?: boolean; content?: { text?: string }[]; structuredContent?: unknown }): any {
164  if (reply.isError) throw new Error(reply.content?.[0]?.text ?? 'InstantNotes refused')
165  if (reply.structuredContent !== undefined) return reply.structuredContent
166  const text = reply.content?.[0]?.text ?? ''
167  try {
168    return JSON.parse(text)
169  } catch {
170    return { text }
171  }
172}
173
174/**
175 * One tools/call, in the stateless 2026-07-28 era: a single request line in,
176 * a single response out, no handshake.
177 */
178async function callProcess($: EngineInterface, argv: readonly string[], name: string, args: Record<string, unknown>): Promise<any> {
179  const request = {
180    jsonrpc: '2.0',
181    id: 1,
182    method: 'tools/call',
183    params: {
184      name,
185      arguments: args,
186      _meta: {
187        'io.modelcontextprotocol/protocolVersion': PROTOCOL,
188        'io.modelcontextprotocol/clientInfo': { name: 'Claude Code', version: '0' },
189      },
190    },
191  }
192  const ran = await $.process.run(argv, { stdin: `${JSON.stringify(request)}\n`, timeoutMs: 15000 })
193  const line = ran.stdout.trim().split('\n').find(one => one.startsWith('{')) ?? ''
194  if (line === '') {
195    throw new Error(ran.stderr.trim() || `instantnotes exited ${ran.exitCode} without answering`)
196  }
197  const reply = JSON.parse(line)
198  if (reply.error) throw new Error(reply.error.message)
199  return resultOf(reply.result)
200}
201
202/** Asks InstantNotes one question, by the server Claude Code runs when it has one, else by a process of its own. */
203async function ask($: EngineInterface, options: Options, name: string, args: Record<string, unknown>): Promise<any> {
204  let from = (await read($, snapshot)).source
205  if (from === null && !mcpIsDown) {
206    try {
207      const reply = await $.mcp.call(SERVER, name, args)
208      const viaMcp: Source = { kind: 'mcp', server: SERVER }
209      await write($, 'source', () => viaMcp)
210      return resultOf(reply)
211    } catch (err) {
212      // Not connected here: the person has not run `claude mcp add`, or it is off.
213      mcpIsDown = true
214      $.ui.log(`instantnotes: no MCP server "${SERVER}" (${reasonOf(err)}); running instantnotes itself`, { to: 'debug' })
215    }
216  }
217  if (from?.kind === 'mcp') {
218    return resultOf(await $.mcp.call(from.server, name, args))
219  }
220  const running = from ?? (await processSource($, options))
221  if (from === null) await write($, 'source', () => running)
222  if (running === null || running.kind !== 'process') throw new Error('InstantNotes is not reachable')
223  try {
224    return await callProcess($, running.argv, name, args)
225  } catch (err) {
226    if (isNotFound(reasonOf(err))) {
227      // Forget the program so the next try looks again (after an install, a /config change).
228      await write($, 'source', () => null)
229    }
230    throw err
231  }
232}
233
234/** Runs one request, showing that it is under way and what went wrong. */
235async function attempt<T>($: EngineInterface, work: () => Promise<T>): Promise<T | undefined> {
236  await write($, 'isBusy', () => true)
237  try {
238    const result = await work()
239    await write($, 'problem', () => null)
240    return result
241  } catch (err) {
242    const message = reasonOf(err)
243    const trouble: Problem = isNotFound(message)
244      ? { message: `InstantNotes could not be started: ${message}`, steps: SETUP_STEPS }
245      : { message, steps: [] }
246    await write($, 'problem', () => trouble)
247    return undefined
248  } finally {
249    await write($, 'isBusy', () => false)
250  }
251}
252
253// ---------------------------------------------------------------- notes
254
255function toHit(found: any): Hit {
256  return {
257    id: String(found.id),
258    title: oneLine(String(found.title ?? '')) || 'Untitled',
259    excerpt: oneLine(String(found.excerpt ?? found.snippet ?? '')),
260    spaces: Array.isArray(found.spaces) ? found.spaces.map(String) : [],
261    updatedAt: typeof found.updatedAt === 'string' ? found.updatedAt : undefined,
262  }
263}
264
265async function search($: EngineInterface, options: Options, text: string, isForced = false): Promise<void> {
266  const words = text.trim()
267  await write($, 'query', () => words)
268  await write($, 'typed', () => words)
269  await write($, 'note', () => null)
270  const now = await $.clock.now()
271  if (words === '' && !isForced && recent !== null && now - recent.at < CACHE_MS) {
272    const kept = recent.hits
273    await write($, 'hits', () => kept)
274    return
275  }
276  const found = await attempt($, async (): Promise<Hit[]> =>
277    words
278      ? ((await ask($, options, 'search_notes', { query: words, limit: LIST_LIMIT })).results ?? []).map(toHit)
279      : ((await ask($, options, 'list_notes', { limit: LIST_LIMIT })).notes ?? []).map(toHit))
280  if (found === undefined) return
281  if (words === '') recent = { hits: found, at: now }
282  await write($, 'hits', () => found)
283}
284
285async function openNote($: EngineInterface, options: Options, id: string, isFresh = false): Promise<void> {
286  const now = await $.clock.now()
287  const known = notes.get(id)
288  if (known && !isFresh && now - known.at < CACHE_MS) {
289    const { note: kept } = known
290    await write($, 'note', () => kept)
291    return
292  }
293  const opened = await attempt($, async (): Promise<OpenNote> => {
294    const view = await ask($, options, 'get_note', { id })
295    return {
296      id: String(view.id ?? id),
297      title: oneLine(String(view.title ?? '')) || 'Untitled',
298      body: String(view.body ?? ''),
299      tags: Array.isArray(view.tags) ? view.tags.map(String) : [],
300      spaces: Array.isArray(view.spaces) ? view.spaces.map(String) : [],
301      updatedAt: String(view.updatedAt ?? ''),
302    }
303  })
304  if (opened === undefined) return
305  notes.set(id, { note: opened, at: now })
306  await write($, 'note', () => opened)
307}
308
309async function createNote($: EngineInterface, options: Options, body: string): Promise<{ id: string; title: string } | undefined> {
310  const made = await attempt($, async () => ask($, options, 'create_note', { body }))
311  if (made === undefined) return undefined
312  recent = null
313  const id = String(made.id ?? '')
314  const title = oneLine(String(made.title ?? '')) || titleOf(body) || 'Untitled'
315  return { id, title }
316}
317
318async function compose($: EngineInterface, options: Options, body: string): Promise<void> {
319  const text = body.trim()
320  if (text === '') return
321  const made = await createNote($, options, text)
322  if (made === undefined) return
323  await write($, 'isComposing', () => false)
324  $.ui.toast(`Saved to InstantNotes: ${made.title}`)
325  if (made.id) await openNote($, options, made.id, true)
326  else await search($, options, '', true)
327}
328
329// ---------------------------------------------------------------- the pane
330
331const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'InstantNotes', focus: true, closeOnEscape: true, columns: 90 })
332
333export const register: Register = (on, options: Options) => {
334  on('session.start', async ($, e, next) => {
335    cwd = e.cwd
336    home = (await $.env.get('HOME')) ?? ''
337    mcpIsDown = false
338    notes.clear()
339    recent = null
340    await write($, 'source', () => null)
341    await $.command.register({ name: 'notes', description: 'Your InstantNotes in a pane (/notes [words])', immediate: true })
342    await $.command.register({ name: 'note', description: 'Capture a note to InstantNotes (/note <text>)', immediate: true })
343    await reseed($)
344
345    return next(e)
346  })
347
348  // /clear: the pane stays up, so what it shows is written back as soon as the host's state is
349  // the new session's (now, or from the next event if that comes later).
350  on('session.end', async ($, e, next) => {
351    const result = await next(e)
352    if (e.reason === 'clear') await reseed($).catch(() => {})
353    return result
354  })
355
356  on('command.run', { command: 'notes' }, async ($, e) => {
357    const opened = await openPane($)
358    await search($, options, e.args)
359    const from = (await read($, snapshot)).source
360    const how = from?.kind === 'mcp' ? ' (through the instantnotes MCP server)' : ''
361    return { text: `InstantNotes pane opened${how}.${opened.isPlaced ? '' : ' Widen the terminal to see it.'}` }
362  })
363
364  on('command.run', { command: 'note' }, async ($, e) => {
365    const text = e.args.trim()
366    if (text === '') {
367      return { text: 'Usage: /note <text>. The first line becomes the title.' }
368    }
369    const made = await createNote($, options, text)
370    if (made === undefined) {
371      const trouble = (await read($, snapshot)).problem
372      return { text: `InstantNotes could not save it: ${trouble?.message ?? 'unknown error'}` }
373    }
374    $.ui.toast(`Saved to InstantNotes: ${made.title}`)
375    return { text: `Saved note "${made.title}".` }
376  })
377
378  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379    const elements = $.ui.resolve(e)
380    const { Box, Button, Markdown, Text } = elements
381    const Field = 'Input' in elements ? elements.Input : undefined
382    const width = Math.max(24, e.props.bodyColumns)
383    const isNarrow = width < NARROW
384    const rule = <Text dimColor>{'─'.repeat(width)}</Text>
385    // Drawn from what this process kept after a /clear; the host gets it back off the render.
386    if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
387    const snap = await read($, snapshot)
388    const { note: opened, problem: trouble, isBusy: busy, source: from } = snap
389    const now = await $.clock.now()
390
391    const status = busy ? (
392      <Text color="yellow">{'◌ Asking InstantNotes…'}</Text>
393    ) : trouble !== null ? (
394      <Box flexDirection="column">
395        <Text color="red" wrap="wrap">{trouble.message}</Text>
396        {trouble.steps.map((step, index) => (
397          <Text key={`step-${index}`} wrap="wrap">{`${index + 1}. ${step}`}</Text>
398        ))}
399      </Box>
400    ) : null
401
402    if (opened !== null) {
403      const { text, cut } = clipBody(opened.body, MARKDOWN_MAX)
404      const meta = [
405        opened.spaces.length ? opened.spaces.join(', ') : '',
406        opened.tags.length ? opened.tags.map(tag => `#${tag}`).join(' ') : '',
407        ago(opened.updatedAt, now) ? `updated ${ago(opened.updatedAt, now)}` : '',
408      ].filter(Boolean)
409
410      return (
411        <Box flexDirection="column">
412          <Box gap={1} flexWrap="wrap">
413            <Button key="back" plain hotkey="b" label="‹ notes" onPress={() => write($, 'note', () => null)} />
414            <Button
415              key="ask"
416              plain
417              hotkey="u"
418              label={isNarrow ? 'mention' : 'mention in prompt'}
419              onPress={() =>
420                $.prompt.fill({
421                  text: `InstantNotes note "${opened.title}" (id ${opened.id}, updated ${opened.updatedAt}): `,
422                  mode: 'append',
423                })
424              }
425            />
426            <Button
427              key="paste"
428              plain
429              hotkey="p"
430              label={isNarrow ? 'paste' : 'paste into prompt'}
431              onPress={() =>
432                $.prompt.fill({
433                  text: `Note "${opened.title}" from InstantNotes:\n\n${clipBody(opened.body, PASTE_MAX).text}\n\n`,
434                  mode: 'append',
435                })
436              }
437            />
438            <Button
439              key="copy"
440              plain
441              hotkey="c"
442              label="copy"
443              onPress={async press => {
444                const copied = await $.ui.copy({ text: opened.body, surface: press.surface })
445                $.ui.toast(copied.isCopied ? 'Copied the note.' : 'Could not copy here.')
446              }}
447            />
448            <Button key="reload" plain hotkey="r" label="reload" onPress={() => openNote($, options, opened.id, true)} />
449          </Box>
450          <Text bold color="cyan" wrap="truncate-end">{opened.title}</Text>
451          {meta.length > 0 && <Text dimColor wrap="truncate-end">{meta.join('  ·  ')}</Text>}
452          {rule}
453          {status}
454          <Markdown key="body" text={text || '_Empty note._'} />
455          {cut > 0 && <Text dimColor>{`… ${cut.toLocaleString()} more characters in InstantNotes`}</Text>}
456        </Box>
457      )
458    }
459
460    const { hits: list, query: ran, typed: typing, isComposing: composing } = snap
461    const shown = typing !== ran ? filterHits(list, typing) : list
462    const isFiltering = typing !== ran && typing.trim() !== ''
463
464    let field = null
465    if (Field) {
466      field = (
467        <Field
468          key="query"
469          label="⌕ "
470          placeholder={isNarrow ? 'search notes' : 'words or a title · Enter searches · empty lists recent notes'}
471          value={typing}
472          autoFocus={composing ? undefined : true}
473          submitLabel="search"
474          onInput={value => void write($, 'typed', () => value)}
475          onSubmit={value => void search($, options, value)}
476        />
477      )
478    } else {
479      field = <Text dimColor>{ran ? `Results for "${ran}"` : 'Recent notes'}</Text>
480    }
481
482    const heading = isFiltering
483      ? `${shown.length} of ${list.length} match "${typing.trim()}"${Field ? ' · Enter searches the library' : ''}`
484      : ran
485        ? `${list.length}${list.length === LIST_LIMIT ? '+' : ''} result${list.length === 1 ? '' : 's'} for "${ran}"`
486        : `Recent notes · ${list.length}`
487
488    return (
489      <Box flexDirection="column">
490        <Box flexDirection="row" gap={1}>
491          <Box flexShrink={0}>
492            <Text bold color="cyan">✎ InstantNotes</Text>
493          </Box>
494          <Box flexGrow={1} flexShrink={1}>
495            <Text dimColor wrap="truncate-start">{from === null ? '' : from.kind === 'mcp' ? 'via MCP server' : `via ${from.argv[0]}`}</Text>
496          </Box>
497          {Field && (
498            <Button key="new" plain label={composing ? 'cancel' : isNarrow ? '+ new' : '+ new note'} onPress={() => write($, 'isComposing', was => !was)} />
499          )}
500          <Button key="recent" plain label="recent" onPress={() => search($, options, '', true)} />
501        </Box>
502        {field}
503        {Field && composing && (
504          <Field
505            key="compose"
506            label="✎ "
507            placeholder={isNarrow ? 'New note' : 'New note: the first line becomes the title'}
508            value=""
509            autoFocus
510            submitLabel="save"
511            onSubmit={value => void compose($, options, value)}
512          />
513        )}
514        <Text dimColor>{heading}</Text>
515        {rule}
516        {status}
517        {trouble === null && !busy && shown.length === 0 && (
518          <Text dimColor>{list.length === 0 ? (ran ? `Nothing in your notes matches "${ran}".` : 'No notes yet. Press + new note to write one.') : 'Nothing loaded matches; press Enter to search the library.'}</Text>
519        )}
520        {shown.map(hit => {
521          const when = ago(hit.updatedAt, now)
522          const tail = [hit.spaces[0] ?? '', when].filter(Boolean).join(' · ')
523          const room = Math.max(8, width - 2 - (tail ? tail.length + 2 : 0))
524
525          return (
526            <Box key={`row-${hit.id}`} flexDirection="column" hover={{ backgroundColor: ROW_BG }}>
527              <Box flexDirection="row">
528                <Text color="cyan">{'▸ '}</Text>
529                <Button key={`hit-${hit.id}`} plain label={clip(hit.title, room)} onPress={() => openNote($, options, hit.id)} />
530                {tail !== '' && !isNarrow && <Text dimColor>{`  ${tail}`}</Text>}
531              </Box>
532              {hit.excerpt !== '' && (
533                <Text dimColor wrap="truncate-end">{`  ${clip(hit.excerpt, width - 3)}`}</Text>
534              )}
535            </Box>
536          )
537        })}
538      </Box>
539    )
540  })
541}
542
hooks/library.ts 128 lines
1// Pure helpers: finding the InstantNotes program and library from what the
2// person already set up, and shaping text for the pane. Nothing here touches
3// `$`, so the tests import them directly.
4
5import type { Hit } from '../types'
6
7export const APP_BINARY = 'InstantNotes.app/Contents/MacOS/instantnotes'
8
9/** An MCP server entry as `claude mcp add` writes it, or as a `.mcp.json` holds it. */
10export type ServerEntry = { command?: unknown; args?: unknown }
11
12/** What one `instantnotes mcp …` command line says: the program, and the library it opens. */
13export type Launch = { binary: string; db?: string; attachments?: string }
14
15/**
16 * Reads the `instantnotes` server out of a Claude Code MCP configuration:
17 * `~/.claude.json` (top level and under `projects[cwd]`) or a `.mcp.json`.
18 * The person set this up from the app's Agents page, so it names the exact
19 * program and library they use.
20 */
21export function launchFromConfig(json: string, cwd?: string): Launch | null {
22  let parsed: any
23  try {
24    parsed = JSON.parse(json)
25  } catch {
26    return null
27  }
28  if (parsed === null || typeof parsed !== 'object') return null
29  const places: unknown[] = [parsed.mcpServers?.instantnotes, cwd === undefined ? undefined : parsed.projects?.[cwd]?.mcpServers?.instantnotes]
30  for (const entry of places) {
31    const launch = launchOf(entry as ServerEntry | undefined)
32    if (launch !== null) return launch
33  }
34  return null
35}
36
37export function launchOf(entry: ServerEntry | undefined): Launch | null {
38  if (!entry || typeof entry.command !== 'string' || entry.command.trim() === '') return null
39  const args = Array.isArray(entry.args) ? entry.args.filter((one): one is string => typeof one === 'string') : []
40  const launch: Launch = { binary: entry.command }
41  for (let i = 0; i < args.length; i += 1) {
42    if (args[i] === '--db' && args[i + 1]) launch.db = args[i + 1]
43    if (args[i] === '--attachments' && args[i + 1]) launch.attachments = args[i + 1]
44  }
45  return launch
46}
47
48/**
49 * The command line that asks `instantnotes mcp` one question. A binary typed
50 * with its own arguments ("…/instantnotes mcp") still works: its words are
51 * kept, a trailing `mcp` dropped, since `mcp` is added here.
52 */
53export function argvFor(binary: string, db: string, attachments: string): string[] {
54  const words = binary.includes(' ') && !binary.startsWith('/') ? binary.trim().split(/\s+/) : [binary.trim()]
55  if (words.length > 1 && words[words.length - 1] === 'mcp') words.pop()
56  return [...words, 'mcp', '--db', db, '--attachments', attachments]
57}
58
59/** The attachments folder beside a library database. */
60export const attachmentsBeside = (db: string) => db.replace(/[^/]*$/, 'attachments')
61
62/** Where the app keeps its library on each platform, under `home`. */
63export const defaultLibraries = (home: string) => ({
64  linux: `${home}/.local/share/com.instantnotes.app/instantnotes.db`,
65  mac: `${home}/Library/Application Support/com.instantnotes.app/instantnotes.db`,
66})
67
68/** Whether an error says the program could not start at all, rather than refusing a request. */
69export const isNotFound = (message: string) => /ENOENT|posix_spawn|not found|cannot start|failed to start|no such file/i.test(message)
70
71export const SETUP_STEPS = [
72  'Open InstantNotes, then Settings › Agents, and turn Access on.',
73  'Copy the "Claude Code" command there and run it once in a terminal (claude mcp add instantnotes …), then restart Claude Code.',
74  'Or set InstantNotes binary in /config to the app\'s executable, such as /Applications/InstantNotes.app/Contents/MacOS/instantnotes.',
75]
76
77// ---------------------------------------------------------------- text
78
79/** Text cut to fit `width` cells, with an ellipsis where it was cut. */
80export const clip = (text: string, width: number) => (text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`)
81
82/** One line of a note's text, control characters and newlines out, as the list shows it. */
83export const oneLine = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()
84
85/** Text as the Markdown element takes it: tab and newline its only control characters. */
86export const printableMarkdown = (text: string) => text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
87
88/** A note's body cut to what one Markdown element may hold, and how much was cut. */
89export function clipBody(body: string, max: number): { text: string; cut: number } {
90  const clean = printableMarkdown(body)
91  if (clean.length <= max) return { text: clean, cut: 0 }
92  const kept = clean.slice(0, max - 1)
93  // Cut at a line end when one is near, so a heading or list is not left half-drawn.
94  const nl = kept.lastIndexOf('\n')
95  const end = nl > max * 0.8 ? nl : kept.length
96  return { text: `${clean.slice(0, end)}…`, cut: clean.length - end }
97}
98
99/** "3 min ago", "yesterday", "2026-09-30": a stamp as the list shows it, against `now`. */
100export function ago(stamp: string | undefined, now: number): string {
101  if (!stamp) return ''
102  const at = Date.parse(stamp)
103  if (Number.isNaN(at)) return ''
104  const s = Math.max(0, (now - at) / 1000)
105  if (s < 60) return 'just now'
106  if (s < 3600) return `${Math.floor(s / 60)} min ago`
107  if (s < 86400) return `${Math.floor(s / 3600)} h ago`
108  if (s < 172800) return 'yesterday'
109  if (s < 7 * 86400) return `${Math.floor(s / 86400)} days ago`
110  return stamp.slice(0, 10)
111}
112
113/** The hits whose title or excerpt holds every word typed, for filtering as the person types. */
114export function filterHits(hits: readonly Hit[], typed: string): Hit[] {
115  const words = typed.toLowerCase().split(/\s+/).filter(Boolean)
116  if (words.length === 0) return [...hits]
117  return hits.filter(hit => {
118    const text = `${hit.title} ${hit.excerpt} ${hit.spaces.join(' ')}`.toLowerCase()
119    return words.every(word => text.includes(word))
120  })
121}
122
123/** The title a body gives a note: its first non-empty line, markdown marks off. */
124export function titleOf(body: string): string {
125  const line = body.split('\n').map(one => one.trim()).find(Boolean) ?? ''
126  return oneLine(line.replace(/^#+\s*/, '').replace(/^[-*]\s+/, ''))
127}
128
types/index.d.ts 39 lines
1/** One search hit or recent note, as the list draws it. */
2export type Hit = { id: string; title: string; excerpt: string; spaces: string[]; updatedAt?: string }
3
4export type OpenNote = {
5  id: string
6  title: string
7  body: string
8  tags: string[]
9  spaces: string[]
10  updatedAt: string
11}
12
13/** How the plugin reaches InstantNotes: the MCP server Claude Code already runs, or its own one-shot process. */
14export type Source = { kind: 'mcp'; server: string } | { kind: 'process'; argv: string[] } | null
15
16/** What went wrong, and when it is a setup matter, what the person can do. */
17export type Problem = { message: string; steps: string[] }
18
19declare module 'claude-code' {
20  interface PluginState {
21    instantnotes: {
22      /** The words the last library search ran with. */
23      query: string
24      /** What is in the search field now, which filters the list as it is typed. */
25      typed: string
26      hits: Hit[]
27      note: OpenNote | null
28      problem: Problem | null
29      /** A call to InstantNotes is in flight. */
30      isBusy: boolean
31      /** The composer for a new note is open in the list view. */
32      isComposing: boolean
33      source: Source
34      /** True once this session's values are written; false after a /clear empties them. */
35      seeded: boolean
36    }
37  }
38}
39