@webinson/peer 0.4.1 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register, RenderChildren, RenderNode } from 'claude-code'
3
+
4
+ import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, Thread } from '../types'
5
+ import type { Shared } from '../types'
6
+
7
+ // The peer CLI's hook writes ~/.peer/sessions/<session id> on `/peer` and removes it on `/peer stop`;
8
+ // that file alone says whether this thread is shared, and in which workspace.
9
+ const shared = atom({ plugin: 'peer-thread-mark', key: 'shared' } as const, null)
10
+
11
+ // The threads pane: /peer-list opens it instead of answering with a blocked prompt, and it reloads while open.
12
+ const PANE = 'peer-threads'
13
+ const RELOAD_MS = 15_000
14
+ // Change the tag when Board's fields change: it declines what an earlier version stored.
15
+ const board = atom({ plugin: 'peer-thread-mark', key: 'board' } as const, null, { shape: 'board-1' })
16
+
17
+ // The memory pane: /peer-thread lists what this thread saved to the person's own OpenViking, in sentences.
18
+ const MEMORY_PANE = 'peer-thread-memories'
19
+ const memories = atom({ plugin: 'peer-thread-mark', key: 'memories' } as const, null, { shape: 'memories-1' })
20
+ // The one memory the pane shows open; every other stays a single line.
21
+ const openMemory = atom({ plugin: 'peer-thread-mark', key: 'openMemory' } as const, null)
22
+
23
+ // The search pane: /peer-all lists all of the person's own memories, newest first, and searches their text.
24
+ const ALL_PANE = 'peer-all-memories'
25
+ const all = atom({ plugin: 'peer-thread-mark', key: 'all' } as const, null, { shape: 'all-2' })
26
+ // The one memory the search pane shows open, by its address.
27
+ const openAll = atom({ plugin: 'peer-thread-mark', key: 'openAll' } as const, null)
28
+
29
+ // A repository key without its host and owner: github.com-semans-peer reads as peer.
30
+ const shortRepo = (repo: string) => (repo === 'global' ? 'everywhere' : repo.replace(/^[^-]+\.[a-z]+-[^-]+-/, '') || repo)
31
+
32
+ // A time as the pane says it: today's as 13:42, this year's as 3 Nov, older ones with the year.
33
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
34
+ function when(at: string, today: Date) {
35
+ const date = new Date(at.replace(' ', 'T') + (at.length > 10 ? ':00Z' : ''))
36
+ if (!at || Number.isNaN(date.getTime())) return ''
37
+ if (date.toDateString() === today.toDateString()) return clock(date.toISOString())
38
+ const day = `${date.getDate()} ${MONTHS[date.getMonth()]}`
39
+ return date.getFullYear() === today.getFullYear() ? day : `${day} ${date.getFullYear()}`
40
+ }
41
+ function dayOf(at: string, today: Date) {
42
+ const date = new Date(at.replace(' ', 'T') + (at.length > 10 ? ':00Z' : ''))
43
+ if (!at || Number.isNaN(date.getTime())) return 'No date'
44
+ const yesterday = new Date(today.getTime() - 86_400_000)
45
+ if (date.toDateString() === today.toDateString()) return 'Today'
46
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
47
+ return when(at.slice(0, 10), today)
48
+ }
49
+
50
+ // A matched line without the Markdown and transcript marks around it: what it says, not how it was stored.
51
+ const snippet = (line: string) =>
52
+ line
53
+ .replace(/\*\*(user|assistant)[^*]*\*\*:\s*/gi, '')
54
+ .replace(/[*`#>]+/g, '')
55
+ .replace(/^\s*[-•]\s+/, '')
56
+ .replace(/\s+/g, ' ')
57
+ .trim()
58
+
59
+ // The words searched for, without quotes or diacritics, to light them where they occur.
60
+ const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
61
+ const searchWords = (text: string) =>
62
+ [...text.matchAll(/"([^"]+)"|(\S+)/g)].map(m => fold((m[1] ?? m[2] ?? '').trim())).filter(word => word.length > 1)
63
+
64
+ // A line with each searched word in it drawn bold; folded the same way, so pamat lights "pamäť".
65
+ function marked(Text: (props: { bold?: boolean; children?: RenderChildren }) => RenderNode, line: string, words: string[]) {
66
+ if (!words.length) return line
67
+ const folded = fold(line)
68
+ const parts: RenderChildren[] = []
69
+ let at = 0
70
+ while (at < line.length) {
71
+ let next = -1
72
+ let length = 0
73
+ for (const word of words) {
74
+ const found = folded.indexOf(word, at)
75
+ if (found >= 0 && (next < 0 || found < next)) [next, length] = [found, word.length]
76
+ }
77
+ if (next < 0) break
78
+ if (next > at) parts.push(line.slice(at, next))
79
+ parts.push(<Text key={`w-${next}`} bold>{line.slice(next, next + length)}</Text>)
80
+ at = next + length
81
+ }
82
+ parts.push(line.slice(at))
83
+ return parts
84
+ }
85
+
86
+ // `/peer-all [words] [here] [--newest | --oldest] [page N]`, as the CLI reads the same words.
87
+ function asked(args: string): AllQuery {
88
+ const words = args.split(/\s+/).filter(Boolean)
89
+ const ask: AllQuery = { text: '', here: false, page: 1, sort: '' }
90
+ const text: string[] = []
91
+ for (let i = 0; i < words.length; i++) {
92
+ if (words[i] === 'here' || words[i] === '--here') ask.here = true
93
+ else if (words[i] === '--newest' || words[i] === '--oldest') ask.sort = words[i] === '--newest' ? 'new' : 'old'
94
+ else if (words[i] === 'page' && /^\d+$/.test(words[i + 1] ?? '')) ask.page = Math.max(1, Number(words[++i]))
95
+ else text.push(words[i]!)
96
+ }
97
+ return { ...ask, text: text.join(' ') }
98
+ }
99
+
100
+ // Typing searches once it pauses this long; Enter searches at once.
101
+ const TYPING_PAUSE_MS = 350
102
+ // Each search is numbered: an answer that comes after a newer search started is dropped, so a slow search for
103
+ // what was typed earlier never covers the results for what is in the field now.
104
+ let asking = 0
105
+ let typing = 0
106
+
107
+ async function loadAll($: EngineInterface, ask: AllQuery) {
108
+ const mine = ++asking
109
+ // The last page stays drawn while the next one loads.
110
+ const before = await read($, all)
111
+ await update($, all, () => ({ ask, loading: true, found: before?.found }))
112
+ let now: AllMemories
113
+ try {
114
+ const sort = { new: ['--newest'], old: ['--oldest'], '': [] }[ask.sort]
115
+ const words = [...ask.text.split(/\s+/).filter(Boolean), ...(ask.here ? ['--here'] : []), ...sort, 'page', String(ask.page)]
116
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'all', '--json', ...words], {
117
+ cwd: (await $.env.get('PWD')) || undefined,
118
+ timeoutMs: 15_000,
119
+ })
120
+ const found = JSON.parse(stdout.trim().split('\n').pop() || '')
121
+ if (exitCode !== 0 || typeof found?.connected !== 'boolean') throw new Error('`peer all --json` did not answer; update peer')
122
+ now = { ask, loading: false, found }
123
+ } catch (error) {
124
+ now = { ask, loading: false, error: error instanceof Error ? error.message : String(error) }
125
+ }
126
+ if (mine === asking) await update($, all, () => now)
127
+ }
128
+
129
+ // The field's text as it is typed: kept at once, so the field shows it, and searched when the typing pauses.
130
+ async function typed($: EngineInterface, ask: AllQuery) {
131
+ const mine = ++typing
132
+ await update($, all, before => (before ? { ...before, ask } : before))
133
+ await $.clock.sleep(TYPING_PAUSE_MS)
134
+ if (mine === typing) await loadAll($, ask)
135
+ }
136
+
137
+ async function peerHome($: EngineInterface) {
138
+ return (await $.env.get('PEER_HOME')) || `${await $.env.get('HOME')}/.peer`
139
+ }
140
+
141
+ async function refresh($: EngineInterface) {
142
+ const home = await peerHome($)
143
+ const before = await read($, shared)
144
+ let now: Shared
145
+ try {
146
+ const marker = JSON.parse(await $.fs.read(`${home}/sessions/${await $.session.id()}`))
147
+ now = { workspace: String(marker.workspace ?? 'Peer'), isShared: true }
148
+ } catch {
149
+ // Not shared, or not readable: keep the band and the workspace it last showed, so stop and start differ
150
+ // only in state. A thread never shared on a computer without Peer stays without a band.
151
+ now = before ? { workspace: before.workspace, isShared: false } : null
152
+ }
153
+ if (before?.workspace !== now?.workspace || before?.isShared !== now?.isShared) {
154
+ await update($, shared, () => now)
155
+ }
156
+ }
157
+
158
+ // The prompt hook exactly as `peer install` put it in Claude Code's settings, split into its arguments.
159
+ async function promptHook($: EngineInterface): Promise<string[] | undefined> {
160
+ const claude = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
161
+ const settings = JSON.parse(await $.fs.read(`${claude}/settings.json`))
162
+ for (const group of settings.hooks?.UserPromptSubmit ?? []) {
163
+ for (const hook of group.hooks ?? []) {
164
+ const command = String(hook.command ?? '')
165
+ if (!command.includes('# peer-hook') || !/\bhook prompt\b/.test(command)) continue
166
+ return [...(command.split('#')[0] ?? '').matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2] ?? '')
167
+ }
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ // The workspace's threads from the same CLI the prompt hook runs, `peer list --json`.
173
+ async function load($: EngineInterface) {
174
+ let now: Board
175
+ try {
176
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'list', '--json'], { timeoutMs: 10_000 })
177
+ const list = JSON.parse(stdout.trim().split('\n').pop() || '')
178
+ if (exitCode !== 0 || !Array.isArray(list?.people)) throw new Error('`peer list --json` did not answer; update peer')
179
+ now = { ...list, loadedAt: await $.clock.now() }
180
+ } catch (error) {
181
+ now = { error: error instanceof Error ? error.message : String(error), loadedAt: await $.clock.now() }
182
+ }
183
+ await update($, board, () => now)
184
+ }
185
+
186
+ // The CLI the prompt hook runs (node and peer.mjs), without its `hook prompt` arguments.
187
+ async function peerCli($: EngineInterface) {
188
+ const argv = await promptHook($)
189
+ if (!argv) throw new Error('no prompt hook in Claude Code settings; run `peer install`')
190
+ return argv.slice(0, argv.indexOf('hook'))
191
+ }
192
+
193
+ async function loadMemories($: EngineInterface) {
194
+ let now: Memories
195
+ try {
196
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'thread', '--json', await $.session.id()], { timeoutMs: 15_000 })
197
+ const found = JSON.parse(stdout.trim().split('\n').pop() || '')
198
+ if (exitCode !== 0 || typeof found?.connected !== 'boolean') throw new Error('`peer thread --json` did not answer; update peer')
199
+ now = { ...found, loadedAt: await $.clock.now() }
200
+ } catch (error) {
201
+ now = { error: error instanceof Error ? error.message : String(error), loadedAt: await $.clock.now() }
202
+ }
203
+ await update($, memories, () => now)
204
+ }
205
+
206
+ // One row of the memory pane. New memories read as their sentence under a name; updates of one memory across
207
+ // saves are one row, newest first, saying what was added last.
208
+ type Entry = { id: string; title: string; when: string; lines: string[] }
209
+
210
+ const titled = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
211
+ const KIND: Record<string, string> = { preferences: 'your preference', profile: 'your profile' }
212
+
213
+ function sections(saves: { at: string; items: MemoryItem[] }[]) {
214
+ const check: Entry[] = []
215
+ const added: Entry[] = []
216
+ const removed: Entry[] = []
217
+ const updated = new Map<string, Entry & { times: number }>()
218
+ for (const save of saves) {
219
+ save.items.forEach((item, index) => {
220
+ const id = `${save.at}-${index}`
221
+ const when = clock(save.at)
222
+ // A memory that holds Claude Code's text is listed once, where it asks for a look.
223
+ if (item.systemText) {
224
+ check.push({ id, title: titled(item.name), when, lines: item.lines })
225
+ return
226
+ }
227
+ if (item.change === 'updated') {
228
+ const name = KIND[item.type] ? `${titled(KIND[item.type]!)}: ${item.name}` : titled(item.name)
229
+ const known = updated.get(name)
230
+ if (known) known.times += 1
231
+ else updated.set(name, { id, title: name, when, lines: item.lines, times: 1 })
232
+ } else {
233
+ const entry = { id, title: titled(item.name), when, lines: item.lines }
234
+ ;(item.change === 'removed' ? removed : added).push(entry)
235
+ }
236
+ })
237
+ }
238
+ const merged = [...updated.values()].map(entry => ({ ...entry, when: entry.times > 1 ? `${entry.when}, ${entry.times}×` : entry.when }))
239
+ return { check, added, updated: merged, removed }
240
+ }
241
+
242
+ const clock = (at: string) => {
243
+ const date = new Date(at)
244
+ return Number.isNaN(date.getTime()) ? at : `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
245
+ }
246
+
247
+ function ago(at: string | null | undefined, now: number) {
248
+ const mins = Math.max(0, Math.round((now - Date.parse(at ?? '')) / 60_000))
249
+ if (!Number.isFinite(mins) || mins < 1) return 'just now'
250
+ if (mins < 60) return `${mins} min`
251
+ return `${Math.floor(mins / 60)} h ${mins % 60} min`
252
+ }
253
+
254
+ // The dot's color and the words beside it, the CLI's list in a glance.
255
+ function state(thread: Thread, now: number): { color: 'success' | 'warning' | 'inactive'; text: string } {
256
+ if (thread.status === 'active') return thread.working ? { color: 'success', text: 'working' } : { color: 'warning', text: 'waits for its person' }
257
+ if (thread.status === 'silent') return { color: 'inactive', text: `silent ${ago(thread.seenAt, now)}` }
258
+ return { color: 'inactive', text: `stopped ${ago(thread.stoppedAt, now)} ago` }
259
+ }
260
+
261
+ // Teammates' text, one line: a branch name cannot draw anything else.
262
+ const plain = (text: unknown) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 80)
263
+
264
+ // One project per repository key, as the CLI groups them; each holds its people and their threads.
265
+ function projects(people: { email: string; threads: Thread[] }[]) {
266
+ const out = new Map<string, { name: string; repoKey?: string | null; people: Map<string, Thread[]> }>()
267
+ for (const person of people) {
268
+ for (const thread of person.threads) {
269
+ const key = thread.repoKey || `name:${thread.repository || 'unknown'}`
270
+ if (!out.has(key)) out.set(key, { name: thread.repository || 'unknown', repoKey: thread.repoKey, people: new Map() })
271
+ const mine = out.get(key)!.people
272
+ mine.set(person.email, [...(mine.get(person.email) ?? []), thread])
273
+ }
274
+ }
275
+ return [...out.values()]
276
+ }
277
+
278
+ // A press does what typing the command does: the CLI's own hook shares or stops the thread and says so.
279
+ // (A plugin may not submit a prompt that starts with `/`, so the hook is run directly.)
280
+ async function send($: EngineInterface, prompt: string) {
281
+ try {
282
+ const argv = await promptHook($)
283
+ if (!argv) {
284
+ $.ui.toast('Peer: no prompt hook in Claude Code settings; run `peer install`')
285
+ return
286
+ }
287
+ const input = { session_id: await $.session.id(), cwd: await $.env.get('PWD'), prompt }
288
+ const { stdout } = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: 10_000 })
289
+ const reason = JSON.parse(stdout.trim().split('\n').pop() || '{}').reason
290
+ if (reason) $.ui.toast(String(reason))
291
+ } catch (error) {
292
+ $.ui.toast(`Peer: ${error instanceof Error ? error.message : String(error)}`)
293
+ }
294
+ await refresh($)
295
+ }
296
+
297
+ export const register: Register = on => {
298
+ // A session that loaded the mod after it started (/reload-plugins, an install) has no state yet: the first
299
+ // drawing looks once, so the band shows without waiting for the next prompt.
300
+ let looked = false
301
+ let reloading: { cancel: () => void } | undefined
302
+ let memoryOpen = false
303
+ const stopReloading = () => {
304
+ reloading?.cancel()
305
+ reloading = undefined
306
+ }
307
+
308
+ on('session.start', async ($, e, next) => {
309
+ const result = await next(e)
310
+ await $.command.register({ name: 'peer-thread', description: "Show what this thread saved to your memory, in a pane" }).catch(() => {})
311
+ // Nothing under the prompt: clear the line an earlier version pinned there.
312
+ $.ui.status(undefined)
313
+ await refresh($).catch(() => {})
314
+ return result
315
+ })
316
+
317
+ // The CLI's prompt hook writes or removes the marker while a typed command goes in, so look after it.
318
+ on('prompt.submit', async ($, e, next) => {
319
+ const result = await next(e)
320
+ await refresh($).catch(() => {})
321
+ return result
322
+ })
323
+
324
+ on('turn.complete', async ($, e, next) => {
325
+ await refresh($).catch(() => {})
326
+ // OpenViking saves a thread's memories when a turn ends past its threshold, so the open pane looks again.
327
+ if (memoryOpen) await loadMemories($).catch(() => {})
328
+ return next(e)
329
+ })
330
+
331
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332
+ if (!looked) {
333
+ looked = true
334
+ await refresh($).catch(() => {})
335
+ }
336
+ const now = await read($, shared)
337
+ if (e.props.hasSurvey || now === null) return next(e)
338
+
339
+ const { Box, Button, Text } = $.ui.resolve(e)
340
+ // One band in both states: the label's own surface says the state (accent when shared, gray when not),
341
+ // and the one control changes in place, stop to start and back.
342
+ return (
343
+ <Box>
344
+ <Text backgroundColor={now.isShared ? 'suggestion' : 'inactive'} color="inverseText" bold>
345
+ {' Peer '}
346
+ </Text>
347
+ <Text bold={now.isShared} dimColor={!now.isShared}>{now.workspace ? ` ${now.workspace} ` : ' '}</Text>
348
+ <Button
349
+ key="toggle"
350
+ label={now.isShared ? '/peer stop' : '/peer start'}
351
+ plain
352
+ dimColor
353
+ onPress={() => send($, now.isShared ? '/peer stop' : '/peer')}
354
+ />
355
+ </Box>
356
+ )
357
+ })
358
+
359
+ // /peer-list opens the pane and runs no command: the prompt never reaches the CLI's hook, so Claude Code shows
360
+ // no "hook blocked your prompt" card. Where no surface can seat a pane, the command runs as before.
361
+ on('command.run', { command: 'peer-list' }, async ($, e, next) => {
362
+ await load($)
363
+ const opened = await $.ui.open({ id: PANE, title: 'Peer' })
364
+ if (!opened.isPlaced) return next(e)
365
+ stopReloading()
366
+ reloading = $.clock.every(RELOAD_MS, () => void load($))
367
+ return { text: 'Peer: the shared threads are in the Peer pane; it reloads every 15 s.' }
368
+ })
369
+
370
+ on('command.run', { command: 'peer-thread' }, async ($, e, next) => {
371
+ await loadMemories($)
372
+ const opened = await $.ui.open({ id: MEMORY_PANE, title: 'This thread' })
373
+ memoryOpen = opened.isPlaced
374
+ return { text: opened.isPlaced ? "Peer: this thread's memories are in the pane." : 'Peer: no room for a pane here; `peer thread <session id>` prints the same list.' }
375
+ })
376
+
377
+ // /peer-all opens the search pane on the words typed after it; without room for a pane, the hook answers.
378
+ on('command.run', { command: 'peer-all' }, async ($, e, next) => {
379
+ await loadAll($, asked(e.args))
380
+ const opened = await $.ui.open({ id: ALL_PANE, title: 'Your memory' })
381
+ if (!opened.isPlaced) return next(e)
382
+ return { text: 'Peer: your memories are in the pane; type there to search them.' }
383
+ })
384
+
385
+ on('ui.close', async ($, e, next) => {
386
+ if (e.id === MEMORY_PANE) memoryOpen = false
387
+ if (e.id === PANE) stopReloading()
388
+ return next(e)
389
+ })
390
+
391
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
392
+ const { Box, Button, Text } = $.ui.resolve(e)
393
+ const now = await read($, board)
394
+ const at = await $.clock.now()
395
+ const reload = <Button key="reload" label="Reload" plain dimColor onPress={() => void load($)} />
396
+ if (now === null) return <Text dimColor>Loading…</Text>
397
+ if ('error' in now) {
398
+ return (
399
+ <Box flexDirection="column">
400
+ <Text color="error">Peer: the list did not load ({now.error})</Text>
401
+ {reload}
402
+ </Box>
403
+ )
404
+ }
405
+ const groups = projects(now.people)
406
+ return (
407
+ <Box flexDirection="column">
408
+ <Box>
409
+ <Text bold>{plain(now.workspace)}</Text>
410
+ <Text dimColor>{` loaded ${ago(new Date(now.loadedAt).toISOString(), at)} ago `}</Text>
411
+ {reload}
412
+ </Box>
413
+ {groups.length === 0 && (
414
+ <Text dimColor>Nobody shares a thread right now. Only threads where someone typed /peer show here.</Text>
415
+ )}
416
+ {groups.map(project => (
417
+ <Box key={project.repoKey ?? project.name} flexDirection="column" marginTop={1}>
418
+ <Text bold>
419
+ {plain(project.name)}
420
+ <Text dimColor>{project.repoKey ? ` ${plain(project.repoKey)}` : ' an older Peer, no repository key'}</Text>
421
+ </Text>
422
+ {[...project.people].map(([email, threads]) => (
423
+ <Box key={email} flexDirection="column">
424
+ <Text dimColor>{`${plain(email)}${email === now.you ? ' (you)' : ''}`}</Text>
425
+ {threads.map(thread => {
426
+ const { color, text } = state(thread, at)
427
+ return (
428
+ <Text key={thread.session}>
429
+ <Text color={color}>{thread.status === 'stopped' ? ' ■ ' : ' ● '}</Text>
430
+ {plain(thread.branch) || '(no branch)'}
431
+ <Text dimColor>{` ${text}`}</Text>
432
+ </Text>
433
+ )
434
+ })}
435
+ </Box>
436
+ ))}
437
+ </Box>
438
+ ))}
439
+ <Box marginTop={1}>
440
+ <Text dimColor>
441
+ {`Silent after ${now.silentAfterMins} min without a signal, stopped after ${now.stoppedAfterMins / 60} h; stopped threads stay ${now.stoppedShownHours} h.`}
442
+ </Text>
443
+ </Box>
444
+ </Box>
445
+ )
446
+ })
447
+
448
+ on('ui.render', { component: 'Pane', requestId: ALL_PANE }, async ($, e) => {
449
+ const elements = $.ui.resolve(e)
450
+ const { Box, Button, Text } = elements
451
+ const now = await read($, all)
452
+ const open = await read($, openAll)
453
+ const today = new Date(await $.clock.now())
454
+ const ask: AllQuery = now?.ask ?? { text: '', here: false, page: 1, sort: '' }
455
+ const go = (change: Partial<AllQuery>) => void loadAll($, { ...ask, page: 1, ...change })
456
+
457
+ // Mobile draws no Input: the list and its pages still show, the words come from /peer-all.
458
+ const search =
459
+ 'Input' in elements ? (
460
+ <elements.Input
461
+ key="q"
462
+ placeholder='Search: words in any order, "a phrase"'
463
+ value={ask.text}
464
+ submitLabel="search"
465
+ autoFocus
466
+ onInput={value => void typed($, { ...ask, page: 1, text: value.trim() })}
467
+ onSubmit={value => {
468
+ typing += 1
469
+ go({ text: value.trim() })
470
+ }}
471
+ />
472
+ ) : (
473
+ ask.text && <Text dimColor>{`"${ask.text}"`}</Text>
474
+ )
475
+ // The switches read as words, lit when on; one press each, the time one goes unsorted, newest, oldest.
476
+ const switches = (
477
+ <Box gap={2}>
478
+ <Button key="here" label="This repository" plain dimColor={!ask.here} onPress={() => go({ here: !ask.here })} />
479
+ <Button
480
+ key="sort"
481
+ label={{ '': 'Time', new: 'Newest ↓', old: 'Oldest ↑' }[ask.sort]}
482
+ plain
483
+ dimColor={!ask.sort}
484
+ onPress={() => go({ sort: ({ '': 'new', new: 'old', old: '' } as const)[ask.sort] })}
485
+ />
486
+ {ask.text ? <Button key="clear" label="Clear" plain dimColor onPress={() => go({ text: '' })} /> : null}
487
+ </Box>
488
+ )
489
+ const found = now?.found
490
+ const count = found?.connected ? (found.total === 1 ? '1 memory' : `${found.total.toLocaleString('en')} memories`) : ''
491
+ const header = (
492
+ <Box flexDirection="column" gap={1}>
493
+ <Box justifyContent="space-between" gap={1}>
494
+ <Text bold>Your memory</Text>
495
+ <Text dimColor tabular size="footnote">{now?.loading ? 'loading…' : count}</Text>
496
+ </Box>
497
+ {search}
498
+ {switches}
499
+ </Box>
500
+ )
501
+ const status = (body: RenderChildren) => (
502
+ <Box flexDirection="column" gap={1}>
503
+ {header}
504
+ {body}
505
+ </Box>
506
+ )
507
+ if (now === null || !found) {
508
+ return status(now?.error ? <Text color="error">{`Peer: your memories did not load (${now.error})`}</Text> : <Text dimColor>Loading…</Text>)
509
+ }
510
+ if (now.error) return status(<Text color="error">{`Peer: your memories did not load (${now.error})`}</Text>)
511
+ if (!found.connected) return status(<Text dimColor>{`No personal OpenViking here (${found.reason}).`}</Text>)
512
+ if (found.total === 0) return status(<Text dimColor>{ask.text ? 'Nothing has those words.' : 'No memories yet.'}</Text>)
513
+
514
+ const words = searchWords(ask.text)
515
+ // One line per memory: its name left, where and when right; under it, when searching, the line that matched.
516
+ // A press opens it in place with every matching line and Copy; one is open at a time.
517
+ const row = (memory: MemoryRow) => {
518
+ const id = memory.uri
519
+ const isOpen = open === id
520
+ const where = [ask.here ? '' : shortRepo(memory.repo), when(memory.at, today)].filter(Boolean).join(' · ')
521
+ const lines = memory.lines.map(snippet).filter(Boolean)
522
+ return (
523
+ <Box key={id} flexDirection="column">
524
+ <Box justifyContent="space-between" gap={1}>
525
+ <Button key={id} plain onPress={() => void update($, openAll, was => (was === id ? null : id))}>
526
+ <Text bold={isOpen} wrap="truncate-end">
527
+ {titled(memory.title)}
528
+ </Text>
529
+ </Button>
530
+ <Text dimColor tabular size="footnote">{where}</Text>
531
+ </Box>
532
+ {!isOpen && lines[0] && (
533
+ <Text dimColor size="footnote" wrap="truncate-end">
534
+ {marked(Text, lines[0], words)}
535
+ </Text>
536
+ )}
537
+ {isOpen && (
538
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
539
+ {lines.map((line, i) => (
540
+ <Text key={`${id}-${i}`} dimColor wrap="wrap">
541
+ {marked(Text, line, words)}
542
+ </Text>
543
+ ))}
544
+ <Text dimColor size="footnote">{`${memory.type} · ${memory.repo === 'global' ? 'every repository' : memory.repo}`}</Text>
545
+ <Button
546
+ key={`${id}-copy`}
547
+ label="Copy address"
548
+ plain
549
+ dimColor
550
+ onPress={press => {
551
+ void $.ui.copy({ text: memory.uri, surface: press.surface })
552
+ $.ui.toast('Copied')
553
+ }}
554
+ />
555
+ </Box>
556
+ )}
557
+ </Box>
558
+ )
559
+ }
560
+ // Ordered by time, the page falls into days, each with its count; unordered, it is one list.
561
+ const days: { day: string; rows: MemoryRow[] }[] = []
562
+ for (const memory of found.rows) {
563
+ const day = ask.sort ? dayOf(memory.at, today) : ''
564
+ if (days.at(-1)?.day !== day) days.push({ day, rows: [] })
565
+ days.at(-1)!.rows.push(memory)
566
+ }
567
+ return (
568
+ <Box flexDirection="column" gap={1}>
569
+ {header}
570
+ {days.map(group => (
571
+ <Box key={`d-${group.day || 'all'}`} flexDirection="column">
572
+ {group.day && (
573
+ <Box justifyContent="space-between">
574
+ <Text dimColor size="caption">{group.day}</Text>
575
+ <Text dimColor tabular size="caption">{String(group.rows.length)}</Text>
576
+ </Box>
577
+ )}
578
+ {group.rows.map(row)}
579
+ </Box>
580
+ ))}
581
+ {found.pages > 1 && (
582
+ <Box justifyContent="space-between" gap={1}>
583
+ {found.page > 1 ? (
584
+ <Button key="prev" label="‹ Newer" plain dimColor onPress={() => void loadAll($, { ...ask, page: found.page - 1 })} />
585
+ ) : (
586
+ <Text> </Text>
587
+ )}
588
+ <Text dimColor tabular size="footnote">{`${found.page} / ${found.pages}`}</Text>
589
+ {found.page < found.pages ? (
590
+ <Button key="next" label="Older ›" plain dimColor onPress={() => void loadAll($, { ...ask, page: found.page + 1 })} />
591
+ ) : (
592
+ <Text> </Text>
593
+ )}
594
+ </Box>
595
+ )}
596
+ <Text dimColor size="caption">{`Edit or delete in OpenViking Studio, ${found.url}/studio/`}</Text>
597
+ </Box>
598
+ )
599
+ })
600
+
601
+ on('ui.render', { component: 'Pane', requestId: MEMORY_PANE }, async ($, e) => {
602
+ const { Box, Button, Text } = $.ui.resolve(e)
603
+ const now = await read($, memories)
604
+ const open = await read($, openMemory)
605
+ const reload = <Button key="reload" label="Reload" plain dimColor onPress={() => void loadMemories($)} />
606
+ if (now === null) return <Text dimColor>Loading…</Text>
607
+ if ('error' in now) {
608
+ return (
609
+ <Box flexDirection="column">
610
+ <Text color="error">Peer: the memories did not load ({now.error})</Text>
611
+ {reload}
612
+ </Box>
613
+ )
614
+ }
615
+ if (!now.connected) return <Text dimColor>{`No personal OpenViking here (${now.reason}).`}</Text>
616
+
617
+ const groups = sections(now.saves)
618
+ const total = groups.check.length + groups.added.length + groups.updated.length + groups.removed.length
619
+
620
+ // A list of one-line rows, name left and time right; the pressed one opens under itself with its text and Copy.
621
+ const row = (entry: Entry, section: string, isCheck: boolean) => {
622
+ const id = `${section}-${entry.id}`
623
+ const isOpen = open === id
624
+ return (
625
+ <Box key={id} flexDirection="column">
626
+ <Box justifyContent="space-between" gap={1}>
627
+ <Button key={id} plain onPress={() => void update($, openMemory, was => (was === id ? null : id))}>
628
+ <Text color={isCheck ? 'warning' : undefined} bold={isOpen} wrap="truncate-end">
629
+ {entry.title}
630
+ </Text>
631
+ </Button>
632
+ <Text dimColor tabular size="footnote">
633
+ {entry.when}
634
+ </Text>
635
+ </Box>
636
+ {isOpen && (
637
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
638
+ {isCheck && <Text color="warning" size="footnote">{"Holds Claude Code's own text, not yours."}</Text>}
639
+ <Text dimColor wrap="wrap">
640
+ {entry.lines.join(' ') || '(no text)'}
641
+ </Text>
642
+ <Button
643
+ key={`${id}-copy`}
644
+ label="Copy"
645
+ plain
646
+ dimColor
647
+ onPress={press => {
648
+ void $.ui.copy({ text: [entry.title, ...entry.lines].join('\n'), surface: press.surface })
649
+ $.ui.toast('Copied')
650
+ }}
651
+ />
652
+ </Box>
653
+ )}
654
+ </Box>
655
+ )
656
+ }
657
+ const section = (title: string, entries: Entry[], isCheck = false) =>
658
+ entries.length > 0 && (
659
+ <Box key={title} flexDirection="column" marginTop={1}>
660
+ <Box justifyContent="space-between">
661
+ <Text dimColor size="caption">{title}</Text>
662
+ <Text dimColor tabular size="caption">{String(entries.length)}</Text>
663
+ </Box>
664
+ {entries.map(entry => row(entry, title, isCheck))}
665
+ </Box>
666
+ )
667
+
668
+ return (
669
+ <Box flexDirection="column">
670
+ <Box justifyContent="space-between" gap={1}>
671
+ <Text bold>This thread</Text>
672
+ <Box gap={1}>
673
+ <Text dimColor size="footnote">{total === 1 ? '1 memory' : `${total} memories`}</Text>
674
+ {reload}
675
+ </Box>
676
+ </Box>
677
+ {total === 0 && <Text dimColor>Nothing saved yet. OpenViking saves after longer turns.</Text>}
678
+ {section('Check', groups.check, true)}
679
+ {section('New', groups.added)}
680
+ {section('Updated', groups.updated)}
681
+ {section('Forgotten', groups.removed)}
682
+ </Box>
683
+ )
684
+ })
685
+ }