@webinson/peer 0.4.2 → 0.4.14

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.
@@ -1,12 +1,176 @@
1
1
  import { atom, read, update } from 'claude-code'
2
- import type { EngineInterface, Register } from 'claude-code'
2
+ import type { EngineInterface, Register, RenderChildren, RenderNode, RenderSurface } from 'claude-code'
3
3
 
4
+ import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, MemoryUse, Recall, RecallItem, Thread } from '../types'
4
5
  import type { Shared } from '../types'
5
6
 
6
7
  // The peer CLI's hook writes ~/.peer/sessions/<session id> on `/peer` and removes it on `/peer stop`;
7
8
  // that file alone says whether this thread is shared, and in which workspace.
8
9
  const shared = atom({ plugin: 'peer-thread-mark', key: 'shared' } as const, null)
9
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
+ // What the hooks added to the session's last prompts, newest last; a prompt's row in the thread finds its own here.
27
+ const RECALLS_KEPT = 30
28
+ const recalls = atom({ plugin: 'peer-thread-mark', key: 'recalls' } as const, [], { shape: 'recalls-1' })
29
+ // The one prompt whose recall line is open, by its message id.
30
+ const openRecall = atom({ plugin: 'peer-thread-mark', key: 'openRecall' } as const, null)
31
+
32
+ // Peer's team memory block: `- [verified pitfall] Title`, a line of text, then `(who, date, uri)`.
33
+ function teamItems(context: string): RecallItem[] {
34
+ const block = /<teammates-project-knowledge[^>]*>([\s\S]*?)<\/teammates-project-knowledge>/.exec(context)?.[1]
35
+ if (!block) return []
36
+ return block
37
+ .split(/\n(?=- \[)/)
38
+ .filter(entry => entry.startsWith('- ['))
39
+ .map(entry => {
40
+ const [head = '', ...rest] = entry.split('\n')
41
+ const kind = /^- \[([^\]]+)\]/.exec(head)?.[1] ?? ''
42
+ const by = /\(([^,]+), (\d{4}-\d{2}-\d{2})?, (viking:\/\/\S+)\)\s*$/.exec(entry)
43
+ return {
44
+ from: 'team' as const,
45
+ title: head.replace(/^- \[[^\]]+\]\s*/, '').trim(),
46
+ detail: [kind.split(' · ')[0], by?.[1], by?.[2]].filter(Boolean).join(' · '),
47
+ uri: by?.[3] ?? '',
48
+ }
49
+ })
50
+ }
51
+
52
+ // OpenViking's recall block: `- [memory 82%] <a memory's text or its address>`, one line each.
53
+ function mineItems(context: string): RecallItem[] {
54
+ const block = /<openviking-context(?![^>]*source="startup")[^>]*>([\s\S]*?)<\/openviking-context>/.exec(context)?.[1]
55
+ if (!block) return []
56
+ return [...block.matchAll(/^- \[(\S+) (\d+)%\] (.+)$/gm)].map(([, kind, score, text = '']) => {
57
+ const isUri = text.startsWith('viking://')
58
+ const name = isUri ? (text.replace(/\/+$/, '').split('/').pop() ?? text).replace(/\.md$/, '').replace(/[_-]+/g, ' ') : text
59
+ return { from: 'mine' as const, title: snippet(name).slice(0, 120), detail: `${kind} · ${score}%`, uri: isUri ? text : '' }
60
+ })
61
+ }
62
+
63
+ // The one memory the search pane shows open, by its address.
64
+ const openAll = atom({ plugin: 'peer-thread-mark', key: 'openAll' } as const, null)
65
+
66
+ // A repository key without its host and owner: github.com-semans-peer reads as peer.
67
+ const shortRepo = (repo: string) => (repo === 'global' ? 'everywhere' : repo.replace(/^[^-]+\.[a-z]+-[^-]+-/, '') || repo)
68
+
69
+ // A time as the pane says it: today's as 13:42, this year's as 3 Nov, older ones with the year.
70
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
71
+ function when(at: string, today: Date) {
72
+ const date = new Date(at.replace(' ', 'T') + (at.length > 10 ? ':00Z' : ''))
73
+ if (!at || Number.isNaN(date.getTime())) return ''
74
+ if (date.toDateString() === today.toDateString()) return clock(date.toISOString())
75
+ const day = `${date.getDate()} ${MONTHS[date.getMonth()]}`
76
+ return date.getFullYear() === today.getFullYear() ? day : `${day} ${date.getFullYear()}`
77
+ }
78
+ function dayOf(at: string, today: Date) {
79
+ const date = new Date(at.replace(' ', 'T') + (at.length > 10 ? ':00Z' : ''))
80
+ if (!at || Number.isNaN(date.getTime())) return 'No date'
81
+ const yesterday = new Date(today.getTime() - 86_400_000)
82
+ if (date.toDateString() === today.toDateString()) return 'Today'
83
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
84
+ return when(at.slice(0, 10), today)
85
+ }
86
+
87
+ // A matched line without the Markdown and transcript marks around it: what it says, not how it was stored.
88
+ const snippet = (line: string) =>
89
+ line
90
+ .replace(/\*\*(user|assistant)[^*]*\*\*:\s*/gi, '')
91
+ .replace(/[*`#>]+/g, '')
92
+ .replace(/^\s*[-•]\s+/, '')
93
+ .replace(/\s+/g, ' ')
94
+ .trim()
95
+
96
+ // The words searched for, without quotes or diacritics, to light them where they occur.
97
+ const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
98
+ const searchWords = (text: string) =>
99
+ [...text.matchAll(/"([^"]+)"|(\S+)/g)].map(m => fold((m[1] ?? m[2] ?? '').trim())).filter(word => word.length > 1)
100
+
101
+ // A line with each searched word in it drawn bold; folded the same way, so pamat lights "pamäť".
102
+ function marked(Text: (props: { bold?: boolean; children?: RenderChildren }) => RenderNode, line: string, words: string[]) {
103
+ if (!words.length) return line
104
+ const folded = fold(line)
105
+ const parts: RenderChildren[] = []
106
+ let at = 0
107
+ while (at < line.length) {
108
+ let next = -1
109
+ let length = 0
110
+ for (const word of words) {
111
+ const found = folded.indexOf(word, at)
112
+ if (found >= 0 && (next < 0 || found < next)) [next, length] = [found, word.length]
113
+ }
114
+ if (next < 0) break
115
+ if (next > at) parts.push(line.slice(at, next))
116
+ parts.push(<Text key={`w-${next}`} bold>{line.slice(next, next + length)}</Text>)
117
+ at = next + length
118
+ }
119
+ parts.push(line.slice(at))
120
+ return parts
121
+ }
122
+
123
+ // `/peer-all [words] [here] [--newest | --oldest] [page N]`, as the CLI reads the same words.
124
+ function asked(args: string): AllQuery {
125
+ const words = args.split(/\s+/).filter(Boolean)
126
+ const ask: AllQuery = { text: '', here: false, page: 1, sort: '' }
127
+ const text: string[] = []
128
+ for (let i = 0; i < words.length; i++) {
129
+ if (words[i] === 'here' || words[i] === '--here') ask.here = true
130
+ else if (words[i] === '--newest' || words[i] === '--oldest') ask.sort = words[i] === '--newest' ? 'new' : 'old'
131
+ else if (words[i] === 'page' && /^\d+$/.test(words[i + 1] ?? '')) ask.page = Math.max(1, Number(words[++i]))
132
+ else text.push(words[i]!)
133
+ }
134
+ return { ...ask, text: text.join(' ') }
135
+ }
136
+
137
+ // Typing searches once it pauses this long; Enter searches at once.
138
+ const TYPING_PAUSE_MS = 350
139
+ // Each search is numbered: an answer that comes after a newer search started is dropped, so a slow search for
140
+ // what was typed earlier never covers the results for what is in the field now.
141
+ let asking = 0
142
+ let typing = 0
143
+
144
+ async function loadAll($: EngineInterface, ask: AllQuery) {
145
+ const mine = ++asking
146
+ // The last page stays drawn while the next one loads.
147
+ const before = await read($, all)
148
+ await update($, all, () => ({ ask, loading: true, found: before?.found }))
149
+ let now: AllMemories
150
+ try {
151
+ const sort = { new: ['--newest'], old: ['--oldest'], '': [] }[ask.sort]
152
+ const words = [...ask.text.split(/\s+/).filter(Boolean), ...(ask.here ? ['--here'] : []), ...sort, 'page', String(ask.page)]
153
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'all', '--json', ...words], {
154
+ cwd: (await $.env.get('PWD')) || undefined,
155
+ timeoutMs: 15_000,
156
+ })
157
+ const found = JSON.parse(stdout.trim().split('\n').pop() || '')
158
+ if (exitCode !== 0 || typeof found?.connected !== 'boolean') throw new Error('`peer all --json` did not answer; update peer')
159
+ now = { ask, loading: false, found }
160
+ } catch (error) {
161
+ now = { ask, loading: false, error: error instanceof Error ? error.message : String(error) }
162
+ }
163
+ if (mine === asking) await update($, all, () => now)
164
+ }
165
+
166
+ // The field's text as it is typed: kept at once, so the field shows it, and searched when the typing pauses.
167
+ async function typed($: EngineInterface, ask: AllQuery) {
168
+ const mine = ++typing
169
+ await update($, all, before => (before ? { ...before, ask } : before))
170
+ await $.clock.sleep(TYPING_PAUSE_MS)
171
+ if (mine === typing) await loadAll($, ask)
172
+ }
173
+
10
174
  async function peerHome($: EngineInterface) {
11
175
  return (await $.env.get('PEER_HOME')) || `${await $.env.get('HOME')}/.peer`
12
176
  }
@@ -28,6 +192,63 @@ async function refresh($: EngineInterface) {
28
192
  }
29
193
  }
30
194
 
195
+ // The memory badge's color: an explicit value, as a surface may not know a theme key; it reads on light and dark.
196
+ const SAVED_COLOR = '#8b5cf6'
197
+ // The soft badge where a surface draws one (desktop, VS Code, mobile): a pale tint and the color as its text, both
198
+ // plain six-digit colors (a surface may refuse a whole drawing over a color with alpha).
199
+ const SOFT_BACKGROUND = '#ede9fe'
200
+ const SOFT_COLOR = '#6d28d9'
201
+ // A memory's kind in a word, and a day as the card says it.
202
+ const KIND_WORD: Record<string, string> = { events: 'event', entities: 'entity', preferences: 'preference', profile: 'profile' }
203
+ function fullDay(at: string) {
204
+ const date = new Date(at)
205
+ return Number.isNaN(date.getTime()) ? '' : `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`
206
+ }
207
+
208
+ // Show in thread: scroll the thread to the message where the surface lets a plugin scroll it (the terminal). The
209
+ // desktop does not, so its own Find gets the message's words (picked by the CLI to be rare in the thread) by ⌘F, ⌘V,
210
+ // and ⌘G for a later match; without the Mac's permission for that, the person is told the keys.
211
+ async function showInThread($: EngineInterface, place: { messageId?: string; messageText?: string; messageMatch?: number }, surface: RenderSurface) {
212
+ const findIt = async () => {
213
+ if (!place.messageText) return $.ui.toast('Peer: this surface cannot scroll the thread to a message')
214
+ await $.ui.copy({ text: place.messageText, surface })
215
+ const match = place.messageMatch ?? 1
216
+ if (await findInApp($, match)) return
217
+ $.ui.toast(
218
+ match > 1
219
+ ? `Copied your message. Press ⌘F, ⌘V, then ⌘G ${match - 1}× (it is match ${match}).`
220
+ : 'Copied your message. Press ⌘F, then ⌘V: the thread scrolls to it.',
221
+ )
222
+ }
223
+ if (!place.messageId) return findIt()
224
+ try {
225
+ const result = await $.ui.scroll({ to: { requestId: place.messageId }, block: 'center' })
226
+ if (result?.deny) await findIt()
227
+ } catch {
228
+ await findIt()
229
+ }
230
+ }
231
+
232
+ // The desktop's Find in page, opened and filled from the clipboard by the keys a person would press: ⌘F, then ⌘V.
233
+ // macOS only (osascript); false when it could not (no Accessibility permission, another system), so the caller
234
+ // falls back to telling the person the two keys.
235
+ async function findInApp($: EngineInterface, match: number) {
236
+ // ⌘G steps to the next match, as Enter would in the find bar, but never sends a message if the bar is not open.
237
+ const next = Array.from({ length: Math.min(Math.max(match - 1, 0), 20) }, () => ['-e', 'delay 0.15', '-e', 'tell application "System Events" to keystroke "g" using command down']).flat()
238
+ try {
239
+ const { exitCode } = await $.process.run(
240
+ ['osascript', '-e', 'tell application "System Events" to keystroke "f" using command down', '-e', 'delay 0.25', '-e', 'tell application "System Events" to keystroke "v" using command down', ...next],
241
+ { timeoutMs: 8_000 },
242
+ )
243
+ return exitCode === 0
244
+ } catch {
245
+ return false
246
+ }
247
+ }
248
+
249
+ // Where a row is the person's own message: typed in the terminal, sent from the desktop or through Remote Control.
250
+ const PERSON = new Set<string | undefined>(['composer', 'sdk', 'bridge'])
251
+
31
252
  // The prompt hook exactly as `peer install` put it in Claude Code's settings, split into its arguments.
32
253
  async function promptHook($: EngineInterface): Promise<string[] | undefined> {
33
254
  const claude = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
@@ -36,12 +257,133 @@ async function promptHook($: EngineInterface): Promise<string[] | undefined> {
36
257
  for (const hook of group.hooks ?? []) {
37
258
  const command = String(hook.command ?? '')
38
259
  if (!command.includes('# peer-hook') || !/\bhook prompt\b/.test(command)) continue
39
- return [...command.split('#')[0].matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2])
260
+ return [...(command.split('#')[0] ?? '').matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2] ?? '')
40
261
  }
41
262
  }
42
263
  return undefined
43
264
  }
44
265
 
266
+ // The workspace's threads from the same CLI the prompt hook runs, `peer list --json`.
267
+ async function load($: EngineInterface) {
268
+ let now: Board
269
+ try {
270
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'list', '--json'], { timeoutMs: 10_000 })
271
+ const list = JSON.parse(stdout.trim().split('\n').pop() || '')
272
+ if (exitCode !== 0 || !Array.isArray(list?.people)) throw new Error('`peer list --json` did not answer; update peer')
273
+ now = { ...list, loadedAt: await $.clock.now() }
274
+ } catch (error) {
275
+ now = { error: error instanceof Error ? error.message : String(error), loadedAt: await $.clock.now() }
276
+ }
277
+ await update($, board, () => now)
278
+ }
279
+
280
+ // The CLI the prompt hook runs (node and peer.mjs), without its `hook prompt` arguments.
281
+ async function peerCli($: EngineInterface) {
282
+ const argv = await promptHook($)
283
+ if (!argv) throw new Error('no prompt hook in Claude Code settings; run `peer install`')
284
+ return argv.slice(0, argv.indexOf('hook'))
285
+ }
286
+
287
+ async function loadMemories($: EngineInterface) {
288
+ let now: Memories
289
+ try {
290
+ const { stdout, exitCode } = await $.process.run([...(await peerCli($)), 'thread', '--json', await $.session.id()], { timeoutMs: 15_000 })
291
+ const found = JSON.parse(stdout.trim().split('\n').pop() || '')
292
+ if (exitCode !== 0 || typeof found?.connected !== 'boolean') throw new Error('`peer thread --json` did not answer; update peer')
293
+ now = { ...found, loadedAt: await $.clock.now() }
294
+ } catch (error) {
295
+ now = { error: error instanceof Error ? error.message : String(error), loadedAt: await $.clock.now() }
296
+ }
297
+ await update($, memories, () => now)
298
+ }
299
+
300
+ // One row of the memory pane. New memories read as their sentence under a name; updates of one memory across
301
+ // saves are one row, newest first, saying what was added last.
302
+ // messageId: the thread's row the memory came from, when it is known; the pane can scroll the thread to it.
303
+ type Entry = { id: string; title: string; when: string; lines: string[]; messageId?: string; messageText?: string; messageMatch?: number }
304
+
305
+ const titled = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
306
+ const KIND: Record<string, string> = { preferences: 'your preference', profile: 'your profile' }
307
+
308
+ function sections(saves: { at: string; items: MemoryItem[] }[]) {
309
+ const check: Entry[] = []
310
+ const added: Entry[] = []
311
+ const removed: Entry[] = []
312
+ const updated = new Map<string, Entry & { times: number }>()
313
+ for (const save of saves) {
314
+ save.items.forEach((item, index) => {
315
+ const id = `${save.at}-${index}`
316
+ const when = clock(save.at)
317
+ // A memory that holds Claude Code's text is listed once, where it asks for a look.
318
+ if (item.systemText) {
319
+ check.push({ id, title: titled(item.name), when, lines: item.lines, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch })
320
+ return
321
+ }
322
+ if (item.change === 'updated') {
323
+ const name = KIND[item.type] ? `${titled(KIND[item.type]!)}: ${item.name}` : titled(item.name)
324
+ const known = updated.get(name)
325
+ if (known) known.times += 1
326
+ else updated.set(name, { id, title: name, when, lines: item.lines, times: 1, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch })
327
+ } else {
328
+ const entry = { id, title: titled(item.name), when, lines: item.lines, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch }
329
+ ;(item.change === 'removed' ? removed : added).push(entry)
330
+ }
331
+ })
332
+ }
333
+ const merged = [...updated.values()].map(entry => ({ ...entry, when: entry.times > 1 ? `${entry.when}, ${entry.times}×` : entry.when }))
334
+ return { check, added, updated: merged, removed }
335
+ }
336
+
337
+ // /peer-thread and the band's Memory button open the same pane; says whether a surface seated it.
338
+ async function openThread($: EngineInterface) {
339
+ await loadMemories($)
340
+ const opened = await $.ui.open({ id: MEMORY_PANE, title: 'This thread' })
341
+ return opened.isPlaced
342
+ }
343
+
344
+ // How many memories the thread holds, as the pane lists them: each new one, each updated one once, each to check;
345
+ // removed ones are gone.
346
+ function memoryCount(saves: { at: string; items: MemoryItem[] }[]) {
347
+ const groups = sections(saves)
348
+ return groups.check.length + groups.added.length + groups.updated.length
349
+ }
350
+
351
+ const clock = (at: string) => {
352
+ const date = new Date(at)
353
+ return Number.isNaN(date.getTime()) ? at : `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
354
+ }
355
+
356
+ function ago(at: string | null | undefined, now: number) {
357
+ const mins = Math.max(0, Math.round((now - Date.parse(at ?? '')) / 60_000))
358
+ if (!Number.isFinite(mins) || mins < 1) return 'just now'
359
+ if (mins < 60) return `${mins} min`
360
+ return `${Math.floor(mins / 60)} h ${mins % 60} min`
361
+ }
362
+
363
+ // The dot's color and the words beside it, the CLI's list in a glance.
364
+ function state(thread: Thread, now: number): { color: 'success' | 'warning' | 'inactive'; text: string } {
365
+ if (thread.status === 'active') return thread.working ? { color: 'success', text: 'working' } : { color: 'warning', text: 'waits for its person' }
366
+ if (thread.status === 'silent') return { color: 'inactive', text: `silent ${ago(thread.seenAt, now)}` }
367
+ return { color: 'inactive', text: `stopped ${ago(thread.stoppedAt, now)} ago` }
368
+ }
369
+
370
+ // Teammates' text, one line: a branch name cannot draw anything else.
371
+ const plain = (text: unknown) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 80)
372
+
373
+ // One project per repository key, as the CLI groups them; each holds its people and their threads.
374
+ function projects(people: { email: string; threads: Thread[] }[]) {
375
+ const out = new Map<string, { name: string; repoKey?: string | null; people: Map<string, Thread[]> }>()
376
+ for (const person of people) {
377
+ for (const thread of person.threads) {
378
+ const key = thread.repoKey || `name:${thread.repository || 'unknown'}`
379
+ if (!out.has(key)) out.set(key, { name: thread.repository || 'unknown', repoKey: thread.repoKey, people: new Map() })
380
+ const mine = out.get(key)!.people
381
+ mine.set(person.email, [...(mine.get(person.email) ?? []), thread])
382
+ }
383
+ }
384
+ return [...out.values()]
385
+ }
386
+
45
387
  // A press does what typing the command does: the CLI's own hook shares or stops the thread and says so.
46
388
  // (A plugin may not submit a prompt that starts with `/`, so the hook is run directly.)
47
389
  async function send($: EngineInterface, prompt: string) {
@@ -65,11 +407,21 @@ export const register: Register = on => {
65
407
  // A session that loaded the mod after it started (/reload-plugins, an install) has no state yet: the first
66
408
  // drawing looks once, so the band shows without waiting for the next prompt.
67
409
  let looked = false
410
+ let reloading: { cancel: () => void } | undefined
411
+ let memoryOpen = false
412
+ const stopReloading = () => {
413
+ reloading?.cancel()
414
+ reloading = undefined
415
+ }
416
+
68
417
  on('session.start', async ($, e, next) => {
69
418
  const result = await next(e)
419
+ await $.command.register({ name: 'peer-thread', description: "Show what this thread saved to your memory, in a pane" }).catch(() => {})
70
420
  // Nothing under the prompt: clear the line an earlier version pinned there.
71
421
  $.ui.status(undefined)
72
422
  await refresh($).catch(() => {})
423
+ // A resumed thread, or one that loaded the mod later, has its Saved lines at once.
424
+ void loadMemories($).catch(() => {})
73
425
  return result
74
426
  })
75
427
 
@@ -82,34 +434,554 @@ export const register: Register = on => {
82
434
 
83
435
  on('turn.complete', async ($, e, next) => {
84
436
  await refresh($).catch(() => {})
437
+ // OpenViking saves a thread's memories when a turn ends past its threshold, so the open pane and the Saved lines
438
+ // under the thread's messages look again.
439
+ await loadMemories($).catch(() => {})
85
440
  return next(e)
86
441
  })
87
442
 
443
+ // What reached the model with a prompt: the hooks beneath (Peer's from settings, OpenViking's plugin) answer
444
+ // first, then the mod reads their additionalContext and keeps it for the prompt's row. It changes nothing.
445
+ on('classic.UserPromptSubmit', async ($, e, next) => {
446
+ const result = await next(e)
447
+ try {
448
+ const context = (result?.additionalContext ?? []).join('\n')
449
+ const items = [...teamItems(context), ...mineItems(context)]
450
+ if (items.length) {
451
+ const recall: Recall = { prompt: String(e.prompt ?? '').trim(), items }
452
+ await update($, recalls, was => [...(was ?? []), recall].slice(-RECALLS_KEPT))
453
+ }
454
+ } catch {}
455
+ return result
456
+ })
457
+
458
+ // Under the person's prompt, quiet lines: what was recalled for it, and what OpenViking saved from it. A press
459
+ // opens either list; nothing shows for a prompt that neither touched.
460
+ on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
461
+ // The person's own messages only: the terminal stamps them composer, the desktop (an SDK host) sdk, Remote
462
+ // Control bridge; notifications and other sessions' messages draw as they are.
463
+ if (!PERSON.has(e.props.origin?.kind)) return next(e)
464
+ const drawn = await next(e)
465
+ const text = String(e.props.text ?? '').trim()
466
+ const recall = [...((await read($, recalls)) ?? [])].reverse().find(r => r.prompt === text)
467
+ const thread = await read($, memories)
468
+ const saved = thread && 'saves' in thread ? thread.saves.flatMap(save => save.items.map(item => ({ ...item, at: save.at }))).filter(item => item.messageId === e.requestId) : []
469
+ if (drawn === undefined) return drawn
470
+ // What the transcript says came with this message: recalls a hook added, and the agent asking OpenViking. It
471
+ // covers messages from before the mod loaded; the live record covers a prompt the transcript has not caught up on.
472
+ const usedHere = thread && 'saves' in thread ? (thread.uses ?? []).filter(use => use.messageId === e.requestId) : []
473
+ const fromTranscript: RecallItem[] = usedHere
474
+ .filter(use => use.where === 'prompt')
475
+ .flatMap(use => use.items.map(title => ({ from: use.from === 'team' ? ('team' as const) : ('mine' as const), title, detail: use.from === 'team' ? "teammate's page" : 'your memory', uri: '' })))
476
+ const asked = usedHere.filter(use => use.where === 'tool')
477
+ if (!recall && !saved.length && !fromTranscript.length && !asked.length) return drawn
478
+ const { Box, Button, Markdown, Text } = $.ui.resolve(e)
479
+ const id = `memory-${e.requestId}`
480
+ const count = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n} ${many}`)
481
+ const isOpen = (await read($, openRecall)) === id
482
+
483
+ // What happened, said once: new memories by name; updates and recalls as counts.
484
+ const fromHere = saved.filter(item => item.exact)
485
+ const nearHere = saved.filter(item => !item.exact)
486
+ const recalled = fromTranscript.length ? fromTranscript : recall?.items ?? []
487
+ // The badge names the main thing (saved, else updated, else recalled); the words beside it name the rest.
488
+ const headline = fromHere.length ? 'Memory saved' : nearHere.length ? 'Memory updated' : recalled.length ? 'Memory recalled' : 'Memory asked'
489
+ const summary = [
490
+ fromHere.length && fromHere.map(item => item.name).join(', '),
491
+ nearHere.length && (fromHere.length ? `updated ${nearHere.length}` : nearHere.map(item => item.name).join(', ')),
492
+ recalled.length && (fromHere.length || nearHere.length ? `recalled ${recalled.length}` : count(recalled.length, 'memory', 'memories')),
493
+ asked.length && (fromHere.length || nearHere.length || recalled.length ? `asked ${asked.length}` : asked.map(use => use.kind).join(', ')),
494
+ ]
495
+ .filter(Boolean)
496
+ .join(' · ')
497
+
498
+ // A badge: on the terminal solid, elsewhere a soft tint of the memory color with the color as its text.
499
+ const pill = (word: string) =>
500
+ e.surface === 'terminal' ? (
501
+ <Text backgroundColor={SAVED_COLOR} color="#ffffff" bold size="caption">{` ${word} `}</Text>
502
+ ) : (
503
+ <Text backgroundColor={SOFT_BACKGROUND} color={SOFT_COLOR} bold size="caption">{` ${word} `}</Text>
504
+ )
505
+ // One memory as a card: its badge and name, the time right; the day and its kind under them; then its own
506
+ // text as written, headings, lists and code included.
507
+ const card = (key: string, word: string, title: RenderChildren, time: string, meta: string, body?: string) => (
508
+ <Box key={key} flexDirection="column" marginTop={1}>
509
+ <Box justifyContent="space-between" gap={1}>
510
+ <Box gap={1}>
511
+ {pill(word)}
512
+ {title}
513
+ </Box>
514
+ <Text dimColor tabular size="footnote">{time}</Text>
515
+ </Box>
516
+ <Text dimColor size="footnote">{meta}</Text>
517
+ {body ? <Markdown key={`${key}-body`} text={body} /> : null}
518
+ </Box>
519
+ )
520
+ const savedCard = (memory: MemoryItem & { at: string }, index: number) =>
521
+ card(
522
+ `s-${e.requestId}-${index}`,
523
+ memory.change === 'added' ? 'Saved' : memory.change === 'removed' ? 'Forgotten' : 'Updated',
524
+ <Text bold wrap="truncate-end">{titled(memory.name)}</Text>,
525
+ clock(memory.at),
526
+ [fullDay(memory.at), KIND_WORD[memory.type] ?? memory.type, memory.exact ? '' : 'near this message'].filter(Boolean).join(' · '),
527
+ memory.body || memory.lines.join('\n'),
528
+ )
529
+ // A recalled memory's name is its button (a button holds text only): a press copies its address.
530
+ const recallCard = (memory: RecallItem, index: number) =>
531
+ card(
532
+ `r-${e.requestId}-${index}`,
533
+ 'Recalled',
534
+ <Button
535
+ key={`r-${e.requestId}-${index}`}
536
+ plain
537
+ onPress={press => {
538
+ if (!memory.uri) return
539
+ void $.ui.copy({ text: memory.uri, surface: press.surface })
540
+ $.ui.toast('Copied its address')
541
+ }}
542
+ >
543
+ <Text bold wrap="truncate-end">{memory.title}</Text>
544
+ </Button>,
545
+ '',
546
+ `${memory.from === 'team' ? 'From a teammate' : 'From your memory'} · ${memory.detail}`,
547
+ )
548
+
549
+ return (
550
+ <Box flexDirection="column">
551
+ {drawn}
552
+ {/* The one accent: the badge, beside the button, as a surface draws a button's own text plainly. */}
553
+ <Box gap={1}>
554
+ {pill(headline)}
555
+ <Button key={id} plain onPress={() => void update($, openRecall, was => (was === id ? null : id))}>
556
+ <Text dimColor size="footnote" wrap="truncate-end">{`${summary} ${isOpen ? '▾' : '▸'}`}</Text>
557
+ </Button>
558
+ </Box>
559
+ {isOpen && (
560
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
561
+ {fromHere.map(savedCard)}
562
+ {nearHere.map((memory, index) => savedCard(memory, fromHere.length + index))}
563
+ {recalled.map(recallCard)}
564
+ {asked.map((use, index) =>
565
+ card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `OpenViking ${use.kind}`),
566
+ )}
567
+ </Box>
568
+ )}
569
+ </Box>
570
+ )
571
+ })
572
+
88
573
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89
574
  if (!looked) {
90
575
  looked = true
91
576
  await refresh($).catch(() => {})
92
577
  }
93
578
  const now = await read($, shared)
94
- if (e.props.hasSurvey || now === null) return next(e)
579
+ // The band leads with this thread's memory: its count in a square, a press away from the /peer-thread pane. It
580
+ // shows once the memory answers or the thread was shared here; the workspace goes unnamed (the thread is in one),
581
+ // and the one control changes in place, stop to start and back.
582
+ const saved = await read($, memories)
583
+ const total = saved && 'saves' in saved ? memoryCount(saved.saves) : undefined
584
+ if (e.props.hasSurvey || (now === null && total === undefined)) return next(e)
585
+ const isShared = now?.isShared ?? false
95
586
 
96
587
  const { Box, Button, Text } = $.ui.resolve(e)
97
- // One band in both states: the label's own surface says the state (accent when shared, gray when not),
98
- // and the one control changes in place, stop to start and back.
99
588
  return (
100
- <Box>
101
- <Text backgroundColor={now.isShared ? 'suggestion' : 'inactive'} color="inverseText" bold>
102
- {' Peer '}
103
- </Text>
104
- <Text bold={now.isShared} dimColor={!now.isShared}>{now.workspace ? ` ${now.workspace} ` : ' '}</Text>
589
+ <Box gap={2}>
590
+ <Button key="memory" label={total === undefined ? 'Memory' : `Memory, ${total}`} plain onPress={async () => { memoryOpen = await openThread($) }}>
591
+ <Text bold>{'Memory '}</Text>
592
+ <Text backgroundColor={total ? 'suggestion' : 'inactive'} color="inverseText" bold tabular>
593
+ {` ${total ?? '–'} `}
594
+ </Text>
595
+ </Button>
105
596
  <Button
106
597
  key="toggle"
107
- label={now.isShared ? '/peer stop' : '/peer start'}
598
+ label={isShared ? '/peer stop' : '/peer start'}
108
599
  plain
109
600
  dimColor
110
- onPress={() => send($, now.isShared ? '/peer stop' : '/peer')}
601
+ onPress={() => send($, isShared ? '/peer stop' : '/peer')}
111
602
  />
112
603
  </Box>
113
604
  )
114
605
  })
606
+
607
+ // /peer-list opens the pane and runs no command: the prompt never reaches the CLI's hook, so Claude Code shows
608
+ // no "hook blocked your prompt" card. Where no surface can seat a pane, the command runs as before.
609
+ on('command.run', { command: 'peer-list' }, async ($, e, next) => {
610
+ await load($)
611
+ const opened = await $.ui.open({ id: PANE, title: 'Peer' })
612
+ if (!opened.isPlaced) return next(e)
613
+ stopReloading()
614
+ reloading = $.clock.every(RELOAD_MS, () => void load($))
615
+ return { text: 'Peer: the shared threads are in the Peer pane; it reloads every 15 s.' }
616
+ })
617
+
618
+ on('command.run', { command: 'peer-thread' }, async ($, e, next) => {
619
+ const placed = await openThread($)
620
+ memoryOpen = placed
621
+ return { text: placed ? "Peer: this thread's memories are in the pane." : 'Peer: no room for a pane here; `peer thread <session id>` prints the same list.' }
622
+ })
623
+
624
+ // /peer-all opens the search pane on the words typed after it; without room for a pane, the hook answers.
625
+ on('command.run', { command: 'peer-all' }, async ($, e, next) => {
626
+ await loadAll($, asked(e.args))
627
+ const opened = await $.ui.open({ id: ALL_PANE, title: 'Your memory' })
628
+ if (!opened.isPlaced) return next(e)
629
+ return { text: 'Peer: your memories are in the pane; type there to search them.' }
630
+ })
631
+
632
+ on('ui.close', async ($, e, next) => {
633
+ if (e.id === MEMORY_PANE) memoryOpen = false
634
+ if (e.id === PANE) stopReloading()
635
+ return next(e)
636
+ })
637
+
638
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
639
+ const { Box, Button, Text } = $.ui.resolve(e)
640
+ const now = await read($, board)
641
+ const at = await $.clock.now()
642
+ const reload = <Button key="reload" label="Reload" plain dimColor onPress={() => void load($)} />
643
+ if (now === null) return <Text dimColor>Loading…</Text>
644
+ if ('error' in now) {
645
+ return (
646
+ <Box flexDirection="column">
647
+ <Text color="error">Peer: the list did not load ({now.error})</Text>
648
+ {reload}
649
+ </Box>
650
+ )
651
+ }
652
+ const groups = projects(now.people)
653
+ return (
654
+ <Box flexDirection="column">
655
+ <Box>
656
+ <Text bold>{plain(now.workspace)}</Text>
657
+ <Text dimColor>{` loaded ${ago(new Date(now.loadedAt).toISOString(), at)} ago `}</Text>
658
+ {reload}
659
+ </Box>
660
+ {groups.length === 0 && (
661
+ <Text dimColor>Nobody shares a thread right now. Only threads where someone typed /peer show here.</Text>
662
+ )}
663
+ {groups.map(project => (
664
+ <Box key={project.repoKey ?? project.name} flexDirection="column" marginTop={1}>
665
+ <Text bold>
666
+ {plain(project.name)}
667
+ <Text dimColor>{project.repoKey ? ` ${plain(project.repoKey)}` : ' an older Peer, no repository key'}</Text>
668
+ </Text>
669
+ {[...project.people].map(([email, threads]) => (
670
+ <Box key={email} flexDirection="column">
671
+ <Text dimColor>{`${plain(email)}${email === now.you ? ' (you)' : ''}`}</Text>
672
+ {threads.map(thread => {
673
+ const { color, text } = state(thread, at)
674
+ return (
675
+ <Text key={thread.session}>
676
+ <Text color={color}>{thread.status === 'stopped' ? ' ■ ' : ' ● '}</Text>
677
+ {plain(thread.branch) || '(no branch)'}
678
+ <Text dimColor>{` ${text}`}</Text>
679
+ </Text>
680
+ )
681
+ })}
682
+ </Box>
683
+ ))}
684
+ </Box>
685
+ ))}
686
+ <Box marginTop={1}>
687
+ <Text dimColor>
688
+ {`Silent after ${now.silentAfterMins} min without a signal, stopped after ${now.stoppedAfterMins / 60} h; stopped threads stay ${now.stoppedShownHours} h.`}
689
+ </Text>
690
+ </Box>
691
+ </Box>
692
+ )
693
+ })
694
+
695
+ on('ui.render', { component: 'Pane', requestId: ALL_PANE }, async ($, e) => {
696
+ const elements = $.ui.resolve(e)
697
+ const { Box, Button, Text } = elements
698
+ const now = await read($, all)
699
+ const open = await read($, openAll)
700
+ const today = new Date(await $.clock.now())
701
+ const ask: AllQuery = now?.ask ?? { text: '', here: false, page: 1, sort: '' }
702
+ const go = (change: Partial<AllQuery>) => void loadAll($, { ...ask, page: 1, ...change })
703
+
704
+ // Mobile draws no Input: the list and its pages still show, the words come from /peer-all.
705
+ const search =
706
+ 'Input' in elements ? (
707
+ <elements.Input
708
+ key="q"
709
+ placeholder='Search: words in any order, "a phrase"'
710
+ value={ask.text}
711
+ submitLabel="search"
712
+ autoFocus
713
+ onInput={value => void typed($, { ...ask, page: 1, text: value.trim() })}
714
+ onSubmit={value => {
715
+ typing += 1
716
+ go({ text: value.trim() })
717
+ }}
718
+ />
719
+ ) : (
720
+ ask.text && <Text dimColor>{`"${ask.text}"`}</Text>
721
+ )
722
+ // The switches read as words, lit when on; one press each, the time one goes unsorted, newest, oldest.
723
+ const switches = (
724
+ <Box gap={2}>
725
+ <Button key="here" label="This repository" plain dimColor={!ask.here} onPress={() => go({ here: !ask.here })} />
726
+ <Button
727
+ key="sort"
728
+ label={{ '': 'Time', new: 'Newest ↓', old: 'Oldest ↑' }[ask.sort]}
729
+ plain
730
+ dimColor={!ask.sort}
731
+ onPress={() => go({ sort: ({ '': 'new', new: 'old', old: '' } as const)[ask.sort] })}
732
+ />
733
+ {ask.text ? <Button key="clear" label="Clear" plain dimColor onPress={() => go({ text: '' })} /> : null}
734
+ </Box>
735
+ )
736
+ const found = now?.found
737
+ const count = found?.connected ? (found.total === 1 ? '1 memory' : `${found.total.toLocaleString('en')} memories`) : ''
738
+ const header = (
739
+ <Box flexDirection="column" gap={1}>
740
+ <Box justifyContent="space-between" gap={1}>
741
+ <Text bold>Your memory</Text>
742
+ <Text dimColor tabular size="footnote">{now?.loading ? 'loading…' : count}</Text>
743
+ </Box>
744
+ {search}
745
+ {switches}
746
+ </Box>
747
+ )
748
+ const status = (body: RenderChildren) => (
749
+ <Box flexDirection="column" gap={1}>
750
+ {header}
751
+ {body}
752
+ </Box>
753
+ )
754
+ if (now === null || !found) {
755
+ return status(now?.error ? <Text color="error">{`Peer: your memories did not load (${now.error})`}</Text> : <Text dimColor>Loading…</Text>)
756
+ }
757
+ if (now.error) return status(<Text color="error">{`Peer: your memories did not load (${now.error})`}</Text>)
758
+ if (!found.connected) return status(<Text dimColor>{`No personal OpenViking here (${found.reason}).`}</Text>)
759
+ if (found.total === 0) return status(<Text dimColor>{ask.text ? 'Nothing has those words.' : 'No memories yet.'}</Text>)
760
+
761
+ const words = searchWords(ask.text)
762
+ // One line per memory: its name left, where and when right; under it, when searching, the line that matched.
763
+ // A press opens it in place with every matching line and Copy; one is open at a time.
764
+ const row = (memory: MemoryRow) => {
765
+ const id = memory.uri
766
+ const isOpen = open === id
767
+ const where = [ask.here ? '' : shortRepo(memory.repo), when(memory.at, today)].filter(Boolean).join(' · ')
768
+ const lines = memory.lines.map(snippet).filter(Boolean)
769
+ return (
770
+ <Box key={id} flexDirection="column">
771
+ <Box justifyContent="space-between" gap={1}>
772
+ <Button key={id} plain onPress={() => void update($, openAll, was => (was === id ? null : id))}>
773
+ <Text bold={isOpen} wrap="truncate-end">
774
+ {titled(memory.title)}
775
+ </Text>
776
+ </Button>
777
+ <Text dimColor tabular size="footnote">{where}</Text>
778
+ </Box>
779
+ {!isOpen && lines[0] && (
780
+ <Text dimColor size="footnote" wrap="truncate-end">
781
+ {marked(Text, lines[0], words)}
782
+ </Text>
783
+ )}
784
+ {isOpen && (
785
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
786
+ {lines.map((line, i) => (
787
+ <Text key={`${id}-${i}`} dimColor wrap="wrap">
788
+ {marked(Text, line, words)}
789
+ </Text>
790
+ ))}
791
+ <Text dimColor size="footnote">{`${memory.type} · ${memory.repo === 'global' ? 'every repository' : memory.repo}`}</Text>
792
+ <Button
793
+ key={`${id}-copy`}
794
+ label="Copy address"
795
+ plain
796
+ dimColor
797
+ onPress={press => {
798
+ void $.ui.copy({ text: memory.uri, surface: press.surface })
799
+ $.ui.toast('Copied')
800
+ }}
801
+ />
802
+ </Box>
803
+ )}
804
+ </Box>
805
+ )
806
+ }
807
+ // Ordered by time, the page falls into days, each with its count; unordered, it is one list.
808
+ const days: { day: string; rows: MemoryRow[] }[] = []
809
+ for (const memory of found.rows) {
810
+ const day = ask.sort ? dayOf(memory.at, today) : ''
811
+ if (days.at(-1)?.day !== day) days.push({ day, rows: [] })
812
+ days.at(-1)!.rows.push(memory)
813
+ }
814
+ return (
815
+ <Box flexDirection="column" gap={1}>
816
+ {header}
817
+ {days.map(group => (
818
+ <Box key={`d-${group.day || 'all'}`} flexDirection="column">
819
+ {group.day && (
820
+ <Box justifyContent="space-between">
821
+ <Text dimColor size="caption">{group.day}</Text>
822
+ <Text dimColor tabular size="caption">{String(group.rows.length)}</Text>
823
+ </Box>
824
+ )}
825
+ {group.rows.map(row)}
826
+ </Box>
827
+ ))}
828
+ {found.pages > 1 && (
829
+ <Box justifyContent="space-between" gap={1}>
830
+ {found.page > 1 ? (
831
+ <Button key="prev" label="‹ Newer" plain dimColor onPress={() => void loadAll($, { ...ask, page: found.page - 1 })} />
832
+ ) : (
833
+ <Text> </Text>
834
+ )}
835
+ <Text dimColor tabular size="footnote">{`${found.page} / ${found.pages}`}</Text>
836
+ {found.page < found.pages ? (
837
+ <Button key="next" label="Older ›" plain dimColor onPress={() => void loadAll($, { ...ask, page: found.page + 1 })} />
838
+ ) : (
839
+ <Text> </Text>
840
+ )}
841
+ </Box>
842
+ )}
843
+ <Text dimColor size="caption">{`Edit or delete in OpenViking Studio, ${found.url}/studio/`}</Text>
844
+ </Box>
845
+ )
846
+ })
847
+
848
+ on('ui.render', { component: 'Pane', requestId: MEMORY_PANE }, async ($, e) => {
849
+ const { Box, Button, Text } = $.ui.resolve(e)
850
+ const now = await read($, memories)
851
+ const open = await read($, openMemory)
852
+ const reload = <Button key="reload" label="Reload" plain dimColor onPress={() => void loadMemories($)} />
853
+ if (now === null) return <Text dimColor>Loading…</Text>
854
+ if ('error' in now) {
855
+ return (
856
+ <Box flexDirection="column">
857
+ <Text color="error">Peer: the memories did not load ({now.error})</Text>
858
+ {reload}
859
+ </Box>
860
+ )
861
+ }
862
+ if (!now.connected) return <Text dimColor>{`No personal OpenViking here (${now.reason}).`}</Text>
863
+
864
+ const groups = sections(now.saves)
865
+ const total = groups.check.length + groups.added.length + groups.updated.length + groups.removed.length
866
+
867
+ // A list of one-line rows, name left and time right; the pressed one opens under itself with its text and Copy.
868
+ const row = (entry: Entry, section: string, isCheck: boolean) => {
869
+ const id = `${section}-${entry.id}`
870
+ const isOpen = open === id
871
+ return (
872
+ <Box key={id} flexDirection="column">
873
+ <Box justifyContent="space-between" gap={1}>
874
+ <Button key={id} plain onPress={() => void update($, openMemory, was => (was === id ? null : id))}>
875
+ <Text color={isCheck ? 'warning' : undefined} bold={isOpen} wrap="truncate-end">
876
+ {entry.title}
877
+ </Text>
878
+ </Button>
879
+ <Text dimColor tabular size="footnote">
880
+ {entry.when}
881
+ </Text>
882
+ </Box>
883
+ {isOpen && (
884
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
885
+ {isCheck && <Text color="warning" size="footnote">{"Holds Claude Code's own text, not yours."}</Text>}
886
+ <Text dimColor wrap="wrap">
887
+ {entry.lines.join(' ') || '(no text)'}
888
+ </Text>
889
+ <Box gap={2}>
890
+ {entry.messageId && (
891
+ // The thread scrolls to the message the memory came from, where the surface lets a plugin scroll
892
+ // it (the terminal). The desktop does not: there the message's words go to the clipboard, and
893
+ // the app's own Find in page (⌘F) scrolls to them.
894
+ <Button
895
+ key={`${id}-show`}
896
+ label="Show in thread"
897
+ plain
898
+ onPress={press => void showInThread($, entry, press.surface)}
899
+ />
900
+ )}
901
+ <Button
902
+ key={`${id}-copy`}
903
+ label="Copy"
904
+ plain
905
+ dimColor
906
+ onPress={press => {
907
+ void $.ui.copy({ text: [entry.title, ...entry.lines].join('\n'), surface: press.surface })
908
+ $.ui.toast('Copied')
909
+ }}
910
+ />
911
+ </Box>
912
+ </Box>
913
+ )}
914
+ </Box>
915
+ )
916
+ }
917
+ const section = (title: string, entries: Entry[], isCheck = false) =>
918
+ entries.length > 0 && (
919
+ <Box key={title} flexDirection="column" marginTop={1}>
920
+ <Box justifyContent="space-between">
921
+ <Text dimColor size="caption">{title}</Text>
922
+ <Text dimColor tabular size="caption">{String(entries.length)}</Text>
923
+ </Box>
924
+ {entries.map(entry => row(entry, title, isCheck))}
925
+ </Box>
926
+ )
927
+
928
+ // Where memory went into the thread, oldest first: one line each, what and when; a press opens which memories,
929
+ // and Show in thread where it came with a message.
930
+ const uses = now.uses ?? []
931
+ const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked OpenViking' }
932
+ const useRow = (use: MemoryUse, index: number) => {
933
+ const id = `use-${index}`
934
+ const isOpen = open === id
935
+ const n = use.items.length
936
+ const amount =
937
+ use.from === 'team'
938
+ ? n === 1 ? "1 teammate's page" : `${n} teammates' pages`
939
+ : use.kind === 'listed' ? `${n === 1 ? '1 memory' : `${n} memories`} listed` : `${n} recalled`
940
+ const what = use.where === 'tool' ? `${USE_WORDS.tool}: ${use.kind}` : `${USE_WORDS[use.where] ?? use.where} · ${amount}`
941
+ return (
942
+ <Box key={id} flexDirection="column">
943
+ <Box justifyContent="space-between" gap={1}>
944
+ <Button key={id} plain onPress={() => void update($, openMemory, was => (was === id ? null : id))}>
945
+ <Text bold={isOpen} wrap="truncate-end">{what}</Text>
946
+ </Button>
947
+ <Text dimColor tabular size="footnote">{clock(use.at)}</Text>
948
+ </Box>
949
+ {isOpen && (
950
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
951
+ {use.items.map((item, i) => (
952
+ <Text key={`${id}-${i}`} dimColor size="footnote" wrap="truncate-end">{`· ${item}`}</Text>
953
+ ))}
954
+ {use.messageId && <Button key={`${id}-show`} label="Show in thread" plain onPress={press => void showInThread($, use, press.surface)} />}
955
+ </Box>
956
+ )}
957
+ </Box>
958
+ )
959
+ }
960
+
961
+ return (
962
+ <Box flexDirection="column">
963
+ <Box justifyContent="space-between" gap={1}>
964
+ <Text bold>This thread</Text>
965
+ <Box gap={1}>
966
+ <Text dimColor size="footnote">{total === 1 ? '1 memory' : `${total} memories`}</Text>
967
+ {reload}
968
+ </Box>
969
+ </Box>
970
+ {uses.length > 0 && (
971
+ <Box flexDirection="column" marginTop={1}>
972
+ <Box justifyContent="space-between">
973
+ <Text dimColor size="caption">Used</Text>
974
+ <Text dimColor tabular size="caption">{String(uses.length)}</Text>
975
+ </Box>
976
+ {uses.map(useRow)}
977
+ </Box>
978
+ )}
979
+ {total === 0 && <Text dimColor>Nothing saved yet. OpenViking saves after longer turns.</Text>}
980
+ {section('Check', groups.check, true)}
981
+ {section('New', groups.added)}
982
+ {section('Updated', groups.updated)}
983
+ {section('Forgotten', groups.removed)}
984
+ </Box>
985
+ )
986
+ })
115
987
  }