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.
@@ -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
+ }