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.

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

Every note you have, one command away.
Add the marketplace:
/plugin marketplace add jamubc/toolbox
Install the plugin:
/plugin install instantnotes@toolbox
| Command | What it does |
|---|---|
/notes | Opens 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:
b goes back.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.r) reads one again.The plugin uses what InstantNotes › Settings › Agents sets up, in this order:
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.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.
/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.~/.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.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./note, or the field in the pane).instantnotes program or the MCP server you connected, never the network. ~/.claude.json is read only to find that server's command line.claude --plugin-dir plugins/instantnotes # load it; edits hot-reload
claude plugin validate plugins/instantnotes
claude plugin test plugins/instantnoteshooks/register.tsx 542 lines1// 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}
542hooks/library.ts 128 lines1// 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}
128types/index.d.ts 39 lines1/** 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