crbro-memory 2.7.2 → 2.8.0
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 +51 -3
- package/SECURITY.md +36 -0
- package/bin/crbro.mjs +1437 -1366
- package/dist/daemon/endpoint.d.ts.map +1 -1
- package/dist/daemon/endpoint.js +7 -0
- package/dist/daemon/endpoint.js.map +1 -1
- package/dist/engine/modinstall.d.ts +215 -0
- package/dist/engine/modinstall.d.ts.map +1 -0
- package/dist/engine/modinstall.js +1342 -0
- package/dist/engine/modinstall.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -1
- package/dist/server.js.map +1 -1
- package/mods/crbro-pending/.claude-plugin/plugin.json +21 -0
- package/mods/crbro-pending/hooks/hooks.json +1 -0
- package/mods/crbro-pending/hooks/register.tsx +576 -0
- package/mods/crbro-pending/hooks/strings.ts +200 -0
- package/mods/crbro-pending/types/index.d.ts +27 -0
- package/package.json +2 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, McpToolResult, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { PendingAction, PendingClosed, PendingItem, PendingLang, PendingSource } from '../types'
|
|
5
|
+
import { STRINGS, langOf } from './strings'
|
|
6
|
+
import type { Strings } from './strings'
|
|
7
|
+
|
|
8
|
+
// CRBRO's open items, above the prompt and in a pane.
|
|
9
|
+
//
|
|
10
|
+
// The band shows the newest open item in full (its «Label:» apart, its
|
|
11
|
+
// «(1) … (2) …» steps one per line, its age in color) and rotates through the
|
|
12
|
+
// rest; /pending (alias /pendientes) opens every item as a card, with a
|
|
13
|
+
// filter, «Work on this», «Done» and «Discard».
|
|
14
|
+
//
|
|
15
|
+
// The list comes from CRBRO itself: crbro_context with no arguments, which
|
|
16
|
+
// only reads. The server is whichever MCP server lists a tool ending in
|
|
17
|
+
// `__crbro_context` (`crbro` in a standard install), found with $.tool.list().
|
|
18
|
+
// When no such server is reachable the brain file is read instead,
|
|
19
|
+
// <CRBRO_PATH or ~/.crbro>/prefrontal/active_context.json. Closing and
|
|
20
|
+
// discarding always go through the server: this mod never writes the brain.
|
|
21
|
+
|
|
22
|
+
const PANE = 'crbro-pending'
|
|
23
|
+
const CONTEXT_TOOL = 'crbro_context'
|
|
24
|
+
const REFRESH_MS = 60_000
|
|
25
|
+
const DAY_MS = 86_400_000
|
|
26
|
+
const MAX_CLOSED = 10
|
|
27
|
+
/**
|
|
28
|
+
* The engine unmounts a drawing past its bounds (20,000 nodes, 100,000
|
|
29
|
+
* characters): the pane draws this many cards, the filter finds the rest.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_CARDS = 40
|
|
32
|
+
const MAX_LINE = 2_000
|
|
33
|
+
/** How long a CRBRO tool call waits for the band to catch up before going on. */
|
|
34
|
+
const CATCH_UP_MS = 1_500
|
|
35
|
+
|
|
36
|
+
/** Age colors, raw so they read the same in light and dark themes. */
|
|
37
|
+
const FRESH = '#16A34A'
|
|
38
|
+
const RECENT = '#D97706'
|
|
39
|
+
const OLD = '#DC2626'
|
|
40
|
+
|
|
41
|
+
const items = atom({ plugin: 'crbro-pending', key: 'items' } as const, [])
|
|
42
|
+
const closed = atom({ plugin: 'crbro-pending', key: 'closed' } as const, [])
|
|
43
|
+
const index = atom({ plugin: 'crbro-pending', key: 'index' } as const, 0)
|
|
44
|
+
const isHidden = atom({ plugin: 'crbro-pending', key: 'isHidden' } as const, false)
|
|
45
|
+
const isCompact = atom({ plugin: 'crbro-pending', key: 'isCompact' } as const, false)
|
|
46
|
+
const showClosed = atom({ plugin: 'crbro-pending', key: 'showClosed' } as const, false)
|
|
47
|
+
const filter = atom({ plugin: 'crbro-pending', key: 'filter' } as const, '')
|
|
48
|
+
const confirming = atom({ plugin: 'crbro-pending', key: 'confirming' } as const, null)
|
|
49
|
+
const busy = atom({ plugin: 'crbro-pending', key: 'busy' } as const, null)
|
|
50
|
+
const error = atom({ plugin: 'crbro-pending', key: 'error' } as const, null)
|
|
51
|
+
const lang = atom({ plugin: 'crbro-pending', key: 'lang' } as const, 'en')
|
|
52
|
+
const server = atom({ plugin: 'crbro-pending', key: 'server' } as const, null)
|
|
53
|
+
const source = atom({ plugin: 'crbro-pending', key: 'source' } as const, null)
|
|
54
|
+
|
|
55
|
+
/** The manifest's `language` option: `auto`, `en` or `es`. Set once per load. */
|
|
56
|
+
let configured = 'auto'
|
|
57
|
+
let lastSeen = ''
|
|
58
|
+
|
|
59
|
+
// ─── Pure helpers (exported for the tests) ──────────────────────
|
|
60
|
+
|
|
61
|
+
function daysOld(added: string, now: number): number | null {
|
|
62
|
+
const at = Date.parse(added)
|
|
63
|
+
return Number.isNaN(at) ? null : Math.floor((now - at) / DAY_MS)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function age(t: Strings, added: string, now: number): string {
|
|
67
|
+
const days = daysOld(added, now)
|
|
68
|
+
if (days === null) return t.noDate
|
|
69
|
+
if (days <= 0) return t.today
|
|
70
|
+
if (days === 1) return t.yesterday
|
|
71
|
+
if (days < 60) return t.daysAgo(days)
|
|
72
|
+
return t.monthsAgo(Math.round(days / 30))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ageColor(added: string, now: number): string {
|
|
76
|
+
const days = daysOld(added, now)
|
|
77
|
+
if (days === null || days > 14) return OLD
|
|
78
|
+
return days <= 3 ? FRESH : RECENT
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shortDate(t: Strings, iso: string): string {
|
|
82
|
+
const at = Date.parse(iso)
|
|
83
|
+
if (Number.isNaN(at)) return ''
|
|
84
|
+
const day = new Date(at)
|
|
85
|
+
return t.shortDate(day.getUTCDate(), t.months[day.getUTCMonth()] ?? '')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* «Fiverr: when…» reads better as the label «Fiverr» and the rest. Only when
|
|
90
|
+
* the head (without parentheses) is short: «Decide the official domain of…: …»
|
|
91
|
+
* is not a label. The colon has to end a word (a space or the end after it),
|
|
92
|
+
* so a drive letter, a URL or a time («C:/…», «https://…», «10:30») is never
|
|
93
|
+
* cut, and one letter alone («C: …») is no label either.
|
|
94
|
+
*/
|
|
95
|
+
export function split(text: string): { label: string | null; body: string } {
|
|
96
|
+
const colon = /:(?=\s|$)/.exec(text)?.index ?? -1
|
|
97
|
+
if (colon > 0) {
|
|
98
|
+
const head = text.slice(0, colon).replace(/\s*\([^)]*\)/g, '').trim()
|
|
99
|
+
if (head.length > 1 && head.length <= 40 && head.split(/\s+/).length <= 6) {
|
|
100
|
+
return { label: head, body: text.slice(colon + 1).trim() }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { label: null, body: text }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Long items carry steps «(1) … (2) …»: each one on its own line. */
|
|
107
|
+
export function lines(body: string): string[] {
|
|
108
|
+
return body
|
|
109
|
+
.split(/\s+(?=\(\d{1,2}\)\s)/)
|
|
110
|
+
.map(line => line.trim())
|
|
111
|
+
.filter(line => line.length > 0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One line cut to MAX_LINE characters, so a huge item cannot blow a drawing's bounds. */
|
|
115
|
+
export function clip(line: string): string {
|
|
116
|
+
return line.length > MAX_LINE ? `${line.slice(0, MAX_LINE - 1)}…` : line
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Lower case without accents, so «campana» finds «campaña». */
|
|
120
|
+
function plain(text: string): string {
|
|
121
|
+
return text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function wrapIndex(n: number, length: number): number {
|
|
125
|
+
return ((n % length) + length) % length
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where the brain lives, resolved as src/engine/brain.ts resolves it:
|
|
130
|
+
* CRBRO_PATH when it is a usable path (a `${…}` or `%VAR%` hole is not one,
|
|
131
|
+
* `~` is the home folder, a relative path is taken inside the home folder),
|
|
132
|
+
* otherwise <home>/.crbro. Forward slashes throughout.
|
|
133
|
+
*/
|
|
134
|
+
export function brainDir(home: string | undefined, crbroPath: string | undefined): string | undefined {
|
|
135
|
+
const base = home?.trim() ? home.trim().replace(/\\/g, '/').replace(/\/+$/, '') : undefined
|
|
136
|
+
const inHome = (rest: string) => (base === undefined ? undefined : rest ? `${base}/${rest}` : base)
|
|
137
|
+
const raw = (crbroPath ?? '').trim().replace(/\\/g, '/')
|
|
138
|
+
if (!raw || /\$\{|%[A-Za-z_][A-Za-z0-9_]*%/.test(raw)) return inHome('.crbro')
|
|
139
|
+
if (raw === '~' || raw.startsWith('~/')) return inHome(raw.slice(2))
|
|
140
|
+
if (!/^(?:[A-Za-z]:\/|\/)/.test(raw)) return inHome(raw)
|
|
141
|
+
return raw.replace(/\/+$/, '')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The server part of `mcp__<server>__crbro_<tool>`, or null. */
|
|
145
|
+
export function serverOf(tool: string): string | null {
|
|
146
|
+
const match = /^mcp__(.+)__crbro_[a-z_]+$/.exec(tool)
|
|
147
|
+
return match?.[1] ?? null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rows(value: unknown): Record<string, unknown>[] {
|
|
151
|
+
if (!Array.isArray(value)) return []
|
|
152
|
+
return value
|
|
153
|
+
.map(p => (typeof p === 'string' ? { id: '', text: p, added: '' } : p))
|
|
154
|
+
.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null && typeof p.text === 'string')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The working context, as crbro_context answers it or as the brain file holds it. */
|
|
158
|
+
export function parse(raw: string): { open: PendingItem[]; done: PendingClosed[] } {
|
|
159
|
+
const brain = JSON.parse(raw) as { pending_tasks?: unknown; recently_closed?: unknown }
|
|
160
|
+
const open = rows(brain.pending_tasks)
|
|
161
|
+
.map(p => ({ id: String(p.id ?? ''), text: String(p.text), added: String(p.added ?? '') }))
|
|
162
|
+
.sort((a, b) => b.added.localeCompare(a.added))
|
|
163
|
+
const done = rows(brain.recently_closed)
|
|
164
|
+
.map(p => ({ id: String(p.id ?? ''), text: String(p.text), added: String(p.added ?? ''), closed: String(p.closed ?? '') }))
|
|
165
|
+
.sort((a, b) => b.closed.localeCompare(a.closed))
|
|
166
|
+
.slice(0, MAX_CLOSED)
|
|
167
|
+
|
|
168
|
+
return { open, done }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What crbro_context closes an item by: its id, or its text for a v1 entry with none. */
|
|
172
|
+
function handle(p: PendingItem): string {
|
|
173
|
+
return p.id || p.text
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function textOf(result: McpToolResult): string {
|
|
177
|
+
return result.content
|
|
178
|
+
.map(block => ('text' in block && typeof block.text === 'string' ? block.text : ''))
|
|
179
|
+
.join('')
|
|
180
|
+
.trim()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Engine work ────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
async function resolveLang($: EngineInterface): Promise<PendingLang> {
|
|
186
|
+
if (configured === 'en' || configured === 'es') return configured
|
|
187
|
+
const asked = [
|
|
188
|
+
await $.env.get('CRBRO_LANG'),
|
|
189
|
+
await $.env.get('LC_ALL'),
|
|
190
|
+
await $.env.get('LC_MESSAGES'),
|
|
191
|
+
await $.env.get('LANG'),
|
|
192
|
+
].find(value => value !== undefined && value.trim() !== '' && value.trim().toLowerCase() !== 'auto')
|
|
193
|
+
if (asked !== undefined) return langOf(asked)
|
|
194
|
+
// Windows rarely sets LANG: the system locale is the next best signal.
|
|
195
|
+
try {
|
|
196
|
+
const locale = new Intl.DateTimeFormat().resolvedOptions().locale
|
|
197
|
+
if (locale) return langOf(locale)
|
|
198
|
+
} catch {
|
|
199
|
+
// No Intl here: English.
|
|
200
|
+
}
|
|
201
|
+
return 'en'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** An explicit language applies as soon as the module loads; only auto waits for session.start. */
|
|
205
|
+
async function words($: EngineInterface): Promise<Strings> {
|
|
206
|
+
if (configured === 'en' || configured === 'es') return STRINGS[configured]
|
|
207
|
+
return STRINGS[await read($, lang)]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function brainFile($: EngineInterface): Promise<string | undefined> {
|
|
211
|
+
const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
|
|
212
|
+
const dir = brainDir(home, await $.env.get('CRBRO_PATH'))
|
|
213
|
+
return dir ? `${dir}/prefrontal/active_context.json` : undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The CRBRO MCP server's name, remembered once seen; null while none is connected. */
|
|
217
|
+
async function findServer($: EngineInterface): Promise<string | null> {
|
|
218
|
+
const known = await read($, server)
|
|
219
|
+
if (known !== null) return known
|
|
220
|
+
let names: string[]
|
|
221
|
+
try {
|
|
222
|
+
names = (await $.tool.list()).filter(tool => tool.mcp).map(tool => tool.name)
|
|
223
|
+
} catch {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
const found = names
|
|
227
|
+
.filter(name => name.endsWith(`__${CONTEXT_TOOL}`))
|
|
228
|
+
.map(name => serverOf(name))
|
|
229
|
+
.filter((name): name is string => name !== null)
|
|
230
|
+
const name = found.includes('crbro') ? 'crbro' : (found[0] ?? null)
|
|
231
|
+
if (name !== null) await update($, server, () => name)
|
|
232
|
+
return name
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function load($: EngineInterface): Promise<void> {
|
|
236
|
+
const t = await words($)
|
|
237
|
+
let data: { open: PendingItem[]; done: PendingClosed[] } | null = null
|
|
238
|
+
let from: PendingSource | null = null
|
|
239
|
+
|
|
240
|
+
const name = await findServer($)
|
|
241
|
+
if (name !== null) {
|
|
242
|
+
try {
|
|
243
|
+
const result = await $.mcp.call(name, CONTEXT_TOOL, {})
|
|
244
|
+
if (result.isError) throw new Error(textOf(result) || t.noDetail)
|
|
245
|
+
data = parse(textOf(result))
|
|
246
|
+
from = { kind: 'mcp', server: name }
|
|
247
|
+
} catch {
|
|
248
|
+
// Gone or failing: forget it, read the file, look for it again next time.
|
|
249
|
+
await update($, server, () => null)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (data === null) {
|
|
254
|
+
try {
|
|
255
|
+
const path = await brainFile($)
|
|
256
|
+
if (!path) throw new Error(t.noHome)
|
|
257
|
+
data = parse(await $.fs.read(path))
|
|
258
|
+
from = { kind: 'file', path }
|
|
259
|
+
} catch (err) {
|
|
260
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
261
|
+
await update($, error, () => message)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const { open, done } = data
|
|
267
|
+
const seen = JSON.stringify([open, done, from])
|
|
268
|
+
if (seen !== lastSeen) {
|
|
269
|
+
lastSeen = seen
|
|
270
|
+
await update($, items, () => open)
|
|
271
|
+
await update($, closed, () => done)
|
|
272
|
+
await update($, source, () => from)
|
|
273
|
+
}
|
|
274
|
+
await update($, error, () => null)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function openList($: EngineInterface): Promise<void> {
|
|
278
|
+
const t = await words($)
|
|
279
|
+
const opened = await $.ui.open({ id: PANE, title: t.paneTitle })
|
|
280
|
+
// A terminal too narrow for a pane keeps it waiting, undrawn: say so.
|
|
281
|
+
if (!opened.isPlaced) $.ui.toast(t.paneNoRoom)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function showAll($: EngineInterface): Promise<{ text: string }> {
|
|
285
|
+
await load($)
|
|
286
|
+
await update($, isHidden, () => false)
|
|
287
|
+
await openList($)
|
|
288
|
+
const count = (await read($, items)).length
|
|
289
|
+
|
|
290
|
+
return { text: (await words($)).commandAnswer(count) }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Leaves the item written in the prompt, for the person to finish and send.
|
|
295
|
+
* What they had typed stays: the item goes after it on a line of its own.
|
|
296
|
+
*/
|
|
297
|
+
async function workOn($: EngineInterface, p: PendingItem): Promise<void> {
|
|
298
|
+
const t = await words($)
|
|
299
|
+
const text = t.workPrompt(p.id, p.text)
|
|
300
|
+
const { text: draft } = await $.prompt.read()
|
|
301
|
+
const filled = draft.trim() === ''
|
|
302
|
+
? await $.prompt.fill({ text })
|
|
303
|
+
: await $.prompt.fill({ text: `\n${text}`, mode: 'append' })
|
|
304
|
+
if (!filled.isFilled) $.ui.toast(t.cannotFill)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function setFilter($: EngineInterface, value: string): Promise<void> {
|
|
308
|
+
await update($, filter, () => value)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function askFirst($: EngineInterface, id: string, action: PendingAction): Promise<void> {
|
|
312
|
+
await update($, confirming, () => ({ id, action }))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cancel($: EngineInterface): Promise<void> {
|
|
316
|
+
await update($, confirming, () => null)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Closes (kept under «recently closed») or discards (not kept) through CRBRO's own tool. */
|
|
320
|
+
async function closeItem($: EngineInterface, p: PendingItem, action: PendingAction): Promise<void> {
|
|
321
|
+
const t = await words($)
|
|
322
|
+
const id = handle(p)
|
|
323
|
+
await update($, confirming, () => null)
|
|
324
|
+
await update($, busy, () => id)
|
|
325
|
+
try {
|
|
326
|
+
const name = await findServer($)
|
|
327
|
+
if (name === null) throw new Error(t.noServer)
|
|
328
|
+
const args = action === 'resolve' ? { resolve_pending: id } : { discard_pending: id }
|
|
329
|
+
const result = await $.mcp.call(name, CONTEXT_TOOL, args)
|
|
330
|
+
if (result.isError) throw new Error(textOf(result).slice(0, 160) || t.noDetail)
|
|
331
|
+
await load($)
|
|
332
|
+
const still = (await read($, items)).some(item => handle(item) === id)
|
|
333
|
+
if (still) $.ui.toast(t.stillOpen(p.id))
|
|
334
|
+
else $.ui.toast(action === 'resolve' ? t.resolved(p.id) : t.discarded(p.id))
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
337
|
+
$.ui.toast(t.closeFailed(message))
|
|
338
|
+
} finally {
|
|
339
|
+
await update($, busy, () => null)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const register: Register = (on, options) => {
|
|
344
|
+
configured = typeof options.language === 'string' ? options.language : 'auto'
|
|
345
|
+
|
|
346
|
+
on('session.start', async ($, e, next) => {
|
|
347
|
+
const chosen = await resolveLang($)
|
|
348
|
+
await update($, lang, () => chosen)
|
|
349
|
+
const t = STRINGS[chosen]
|
|
350
|
+
await $.command.register({ name: 'pending', description: t.commandDescription })
|
|
351
|
+
await $.command.register({ name: 'pendientes', description: t.aliasDescription })
|
|
352
|
+
await load($)
|
|
353
|
+
$.clock.every(REFRESH_MS, () => {
|
|
354
|
+
void load($)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
return next(e)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
on('command.run', { command: 'pending' }, async $ => showAll($))
|
|
361
|
+
on('command.run', { command: 'pendientes' }, async $ => showAll($))
|
|
362
|
+
|
|
363
|
+
// What CRBRO does (crbro_context, crbro_consolidate…) can open or close
|
|
364
|
+
// items: after each of its tools the list is read again. The call also
|
|
365
|
+
// names the server, so a server that connected late is found here. Only
|
|
366
|
+
// CRBRO's own tools come through this hook.
|
|
367
|
+
on('tool.call', { tool: /^mcp__.+__crbro_[a-z_]+$/ }, async ($, e, next) => {
|
|
368
|
+
const ran = await next(e)
|
|
369
|
+
const name = serverOf(e.tool)
|
|
370
|
+
if (name !== null) {
|
|
371
|
+
if ((await read($, server)) === null) await update($, server, () => name)
|
|
372
|
+
// The band catches up before the turn goes on, but a slow server never
|
|
373
|
+
// holds the turn past CATCH_UP_MS: the minute's refresh covers the rest.
|
|
374
|
+
await Promise.race([
|
|
375
|
+
load($),
|
|
376
|
+
$.clock.sleep(CATCH_UP_MS, { signal: next.signal }).catch(() => undefined),
|
|
377
|
+
])
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return ran
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// The band: the item in full (or one line, compacted) and the navigation.
|
|
384
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
385
|
+
const list = await read($, items)
|
|
386
|
+
if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
|
|
387
|
+
return next(e)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
391
|
+
const t = await words($)
|
|
392
|
+
const now = await $.clock.now()
|
|
393
|
+
const current = wrapIndex(await read($, index), list.length)
|
|
394
|
+
const item = list[current]
|
|
395
|
+
if (item === undefined) return next(e)
|
|
396
|
+
const compact = await read($, isCompact)
|
|
397
|
+
const { label, body } = split(item.text)
|
|
398
|
+
const isMany = list.length > 1
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<Box flexDirection="column" width="100%">
|
|
402
|
+
<Box flexDirection="row" gap={1}>
|
|
403
|
+
<Text color="claude" bold>
|
|
404
|
+
◆ CRBRO
|
|
405
|
+
</Text>
|
|
406
|
+
<Text dimColor>{t.bandCount(list.length)}</Text>
|
|
407
|
+
{isMany && <Button key="prev" label="‹" plain onPress={() => update($, index, n => n - 1)} />}
|
|
408
|
+
{isMany && (
|
|
409
|
+
<Text dimColor>
|
|
410
|
+
{current + 1}/{list.length}
|
|
411
|
+
</Text>
|
|
412
|
+
)}
|
|
413
|
+
{isMany && <Button key="next" label="›" plain onPress={() => update($, index, n => n + 1)} />}
|
|
414
|
+
<Text color={ageColor(item.added, now)} bold>
|
|
415
|
+
{age(t, item.added, now)}
|
|
416
|
+
</Text>
|
|
417
|
+
<Box flexGrow={1} />
|
|
418
|
+
<Button
|
|
419
|
+
key="mode"
|
|
420
|
+
label={compact ? t.readAll : t.compact}
|
|
421
|
+
plain
|
|
422
|
+
onPress={() => update($, isCompact, c => !c)}
|
|
423
|
+
/>
|
|
424
|
+
<Button key="all" label={t.seeAll} onPress={() => openList($)} />
|
|
425
|
+
<Button key="hide" label={t.hide} onPress={() => update($, isHidden, () => true)} />
|
|
426
|
+
</Box>
|
|
427
|
+
{compact ? (
|
|
428
|
+
<Text wrap="truncate-end">
|
|
429
|
+
{label !== null && <Text bold>{label}: </Text>}
|
|
430
|
+
{clip(body)}
|
|
431
|
+
</Text>
|
|
432
|
+
) : (
|
|
433
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
434
|
+
{label !== null && (
|
|
435
|
+
<Text color="claude" bold>
|
|
436
|
+
{label}
|
|
437
|
+
</Text>
|
|
438
|
+
)}
|
|
439
|
+
{lines(body).map(line => (
|
|
440
|
+
<Text wrap="wrap">{clip(line)}</Text>
|
|
441
|
+
))}
|
|
442
|
+
</Box>
|
|
443
|
+
)}
|
|
444
|
+
</Box>
|
|
445
|
+
)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// The pane: every open item as a card, with a filter and the actions.
|
|
449
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
450
|
+
const kit = $.ui.resolve(e)
|
|
451
|
+
const { Box, Button, Text } = kit
|
|
452
|
+
// Mobile draws no text field: the filter is left out there.
|
|
453
|
+
const Input = 'Input' in kit ? kit.Input : null
|
|
454
|
+
const t = await words($)
|
|
455
|
+
const list = await read($, items)
|
|
456
|
+
const done = await read($, closed)
|
|
457
|
+
const problem = await read($, error)
|
|
458
|
+
const hidden = await read($, isHidden)
|
|
459
|
+
const query = await read($, filter)
|
|
460
|
+
const asking = await read($, confirming)
|
|
461
|
+
const working = await read($, busy)
|
|
462
|
+
const isClosedOpen = await read($, showClosed)
|
|
463
|
+
const from = await read($, source)
|
|
464
|
+
const now = await $.clock.now()
|
|
465
|
+
|
|
466
|
+
const needle = plain(query.trim())
|
|
467
|
+
const shown = needle ? list.filter(p => plain(`${p.id} ${p.text}`).includes(needle)) : list
|
|
468
|
+
const fresh = list.filter(p => (daysOld(p.added, now) ?? 99) <= 3).length
|
|
469
|
+
const old = list.filter(p => (daysOld(p.added, now) ?? 99) > 14).length
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<Box flexDirection="column" gap={1}>
|
|
473
|
+
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
474
|
+
<Text color="claude" bold>
|
|
475
|
+
{t.heading}
|
|
476
|
+
</Text>
|
|
477
|
+
<Text bold>{t.openCount(list.length)}</Text>
|
|
478
|
+
</Box>
|
|
479
|
+
{list.length > 0 && (
|
|
480
|
+
<Box flexDirection="row" columnGap={2} flexWrap="wrap">
|
|
481
|
+
<Text color={FRESH}>{t.fresh(fresh)}</Text>
|
|
482
|
+
<Text color={RECENT}>{t.recent(list.length - fresh - old)}</Text>
|
|
483
|
+
<Text color={OLD}>{t.old(old)}</Text>
|
|
484
|
+
</Box>
|
|
485
|
+
)}
|
|
486
|
+
{problem !== null && <Text color="warning">{t.readError(problem)}</Text>}
|
|
487
|
+
{Input !== null && list.length > 0 && (
|
|
488
|
+
<Input
|
|
489
|
+
key="filter"
|
|
490
|
+
placeholder={t.filterPlaceholder}
|
|
491
|
+
value={query}
|
|
492
|
+
onInput={(value: string) => setFilter($, value)}
|
|
493
|
+
onSubmit={(value: string) => setFilter($, value)}
|
|
494
|
+
/>
|
|
495
|
+
)}
|
|
496
|
+
{needle.length > 0 && <Text dimColor>{t.matches(shown.length, list.length, query.trim())}</Text>}
|
|
497
|
+
{list.length === 0 && problem === null && <Text dimColor>{t.none}</Text>}
|
|
498
|
+
|
|
499
|
+
{shown.slice(0, MAX_CARDS).map((p, i) => {
|
|
500
|
+
const { label, body } = split(p.text)
|
|
501
|
+
const color = ageColor(p.added, now)
|
|
502
|
+
const id = handle(p)
|
|
503
|
+
const key = p.id || `n${i}`
|
|
504
|
+
const isAsking = asking !== null && asking.id === id
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<Box key={`p-${key}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
|
|
508
|
+
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
509
|
+
{label !== null && (
|
|
510
|
+
<Text color="claude" bold>
|
|
511
|
+
{label}
|
|
512
|
+
</Text>
|
|
513
|
+
)}
|
|
514
|
+
<Text color={color} bold>
|
|
515
|
+
{age(t, p.added, now)}
|
|
516
|
+
</Text>
|
|
517
|
+
<Text dimColor>{[shortDate(t, p.added), p.id].filter(Boolean).join(' · ')}</Text>
|
|
518
|
+
</Box>
|
|
519
|
+
{lines(body).map(line => (
|
|
520
|
+
<Text wrap="wrap">{clip(line)}</Text>
|
|
521
|
+
))}
|
|
522
|
+
{working === id ? (
|
|
523
|
+
<Text dimColor>{t.closing}</Text>
|
|
524
|
+
) : isAsking ? (
|
|
525
|
+
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
526
|
+
<Text color="warning" bold>
|
|
527
|
+
{asking.action === 'resolve' ? t.askResolve : t.askDiscard}
|
|
528
|
+
</Text>
|
|
529
|
+
<Button key={`yes-${key}`} label={t.yes} variant="primary" onPress={() => closeItem($, p, asking.action)} />
|
|
530
|
+
<Button key={`no-${key}`} label={t.no} onPress={() => cancel($)} />
|
|
531
|
+
</Box>
|
|
532
|
+
) : (
|
|
533
|
+
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
534
|
+
<Button key={`work-${key}`} label={t.workOn} variant="primary" onPress={() => workOn($, p)} />
|
|
535
|
+
<Button key={`done-${key}`} label={t.done} onPress={() => askFirst($, id, 'resolve')} />
|
|
536
|
+
<Button key={`drop-${key}`} label={t.discard} onPress={() => askFirst($, id, 'discard')} />
|
|
537
|
+
</Box>
|
|
538
|
+
)}
|
|
539
|
+
</Box>
|
|
540
|
+
)
|
|
541
|
+
})}
|
|
542
|
+
{shown.length > MAX_CARDS && <Text dimColor>{t.more(shown.length - MAX_CARDS)}</Text>}
|
|
543
|
+
|
|
544
|
+
{done.length > 0 && (
|
|
545
|
+
<Button
|
|
546
|
+
key="closed"
|
|
547
|
+
label={t.recentlyClosed(isClosedOpen, done.length)}
|
|
548
|
+
plain
|
|
549
|
+
onPress={() => update($, showClosed, open => !open)}
|
|
550
|
+
/>
|
|
551
|
+
)}
|
|
552
|
+
{isClosedOpen &&
|
|
553
|
+
done.map((p, i) => (
|
|
554
|
+
<Box key={`c-${p.id || `n${i}`}`} flexDirection="column" paddingLeft={2}>
|
|
555
|
+
<Text color={FRESH}>{t.closedOn(shortDate(t, p.closed), p.id)}</Text>
|
|
556
|
+
<Text dimColor wrap="wrap">
|
|
557
|
+
{clip(p.text)}
|
|
558
|
+
</Text>
|
|
559
|
+
</Box>
|
|
560
|
+
))}
|
|
561
|
+
|
|
562
|
+
<Text dimColor>{t.help}</Text>
|
|
563
|
+
{from !== null && (
|
|
564
|
+
<Text key="source" dimColor>
|
|
565
|
+
{from.kind === 'mcp' ? t.fromServer(from.server) : t.fromFile(from.path)}
|
|
566
|
+
</Text>
|
|
567
|
+
)}
|
|
568
|
+
<Box flexDirection="row" gap={1}>
|
|
569
|
+
<Button key="reload" label={t.reload} onPress={() => load($)} />
|
|
570
|
+
<Button key="band" label={hidden ? t.showBand : t.hideBand} onPress={() => update($, isHidden, h => !h)} />
|
|
571
|
+
<Button key="close" label={t.close} role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
|
|
572
|
+
</Box>
|
|
573
|
+
</Box>
|
|
574
|
+
)
|
|
575
|
+
})
|
|
576
|
+
}
|