@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.
- package/README.md +23 -7
- package/bin/peer.mjs +512 -13
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +2 -2
- package/mod/peer-thread-mark/hooks/register.tsx +884 -12
- package/mod/peer-thread-mark/tests/all.test.tsx +107 -0
- package/mod/peer-thread-mark/tests/band.test.tsx +59 -0
- package/mod/peer-thread-mark/tests/memories.test.tsx +54 -0
- package/mod/peer-thread-mark/tests/recall.test.tsx +70 -0
- package/mod/peer-thread-mark/tests/saved.test.tsx +148 -0
- package/mod/peer-thread-mark/types/index.d.ts +62 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
<
|
|
102
|
-
{'
|
|
103
|
-
|
|
104
|
-
|
|
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={
|
|
598
|
+
label={isShared ? '/peer stop' : '/peer start'}
|
|
108
599
|
plain
|
|
109
600
|
dimColor
|
|
110
|
-
onPress={() => send($,
|
|
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
|
}
|