memory-eternal 0.11.0 → 0.13.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,535 @@
1
+ #!/usr/bin/env node
2
+ // Memory Atlas TUI 原型
3
+ // 运行:node dsh-tui-plugin/demo.mjs
4
+ // 只读预览:node dsh-tui-plugin/demo.mjs --once
5
+ //
6
+ // 这是一个独立的终端 UI 演示,不连接真实 vault,也不会写入任何卡片。
7
+
8
+ import readline from 'node:readline'
9
+
10
+ const C = {
11
+ reset: '\x1b[0m',
12
+ ember: '\x1b[38;2;255;122;69m',
13
+ emberBg: '\x1b[48;2;66;44;37m',
14
+ green: '\x1b[38;2;74;222;128m',
15
+ red: '\x1b[38;2;248;113;113m',
16
+ amber: '\x1b[38;2;251;191;36m',
17
+ blue: '\x1b[38;2;122;162;255m',
18
+ violet: '\x1b[38;2;183;156;255m',
19
+ cyan: '\x1b[38;2;103;232;249m',
20
+ pink: '\x1b[38;2;244;114;182m',
21
+ text: '\x1b[38;2;236;238;242m',
22
+ dim: '\x1b[38;2;126;135;146m',
23
+ faint: '\x1b[38;2;86;95;107m',
24
+ selectedBg: '\x1b[48;2;54;44;40m',
25
+ chipBg: '\x1b[48;2;66;44;37m',
26
+ keyBg: '\x1b[48;2;46;51;60m',
27
+ calloutBg: '\x1b[48;2;38;42;49m',
28
+ border: '\x1b[38;2;72;80;91m',
29
+ }
30
+
31
+ const KIND = {
32
+ project: { label: '项目', color: C.blue, mark: '◆' },
33
+ knowledge: { label: '知识', color: C.violet, mark: '◇' },
34
+ mistake: { label: '教训', color: C.red, mark: '▲' },
35
+ content: { label: '内容', color: C.pink, mark: '▣' },
36
+ tool: { label: '工具', color: C.cyan, mark: '⚙' },
37
+ prompt: { label: '提示词', color: C.amber, mark: '✦' },
38
+ }
39
+
40
+ const CARDS = [
41
+ {
42
+ id: 'memory-eternal-v5',
43
+ kind: 'project',
44
+ title: 'memory-eternal V5 收口:跨宿主入口',
45
+ source: 'deepseek-harness',
46
+ time: '10:52',
47
+ tags: ['memory-eternal', 'V5', 'UI'],
48
+ summary: '一套 Web UI 作为唯一真源,各宿主只做 Launcher;能内嵌的用 Webview,不能内嵌的只做启动器。',
49
+ body: [
50
+ '变更结论',
51
+ '1. 记忆库改成 4 列卡片 + 今日回看',
52
+ '2. 待审改成顶部类型条 + 右侧判词',
53
+ '3. 星图默认缩小,DPR/网格/边优化',
54
+ '4. 读卡器补目录与相关记忆',
55
+ '',
56
+ '边界',
57
+ '· 不复制第二套 UI',
58
+ '· 不新增 agent 审批权限',
59
+ '· 不修改 DSH 官方源码',
60
+ ],
61
+ },
62
+ {
63
+ id: 'mvcheck-camera',
64
+ kind: 'knowledge',
65
+ title: 'MVCheck 多相机独立成像参数',
66
+ source: 'deepseek-harness',
67
+ time: '10:48',
68
+ tags: ['MVCheck', '相机', '多视角'],
69
+ summary: '每路相机独立 SDK 实例,shared_sdk 降级为运行库保活;按序列号绑定,不再全局覆盖。',
70
+ body: [
71
+ '根因',
72
+ '· 全局 shared_sdk 只有一个 handle',
73
+ '· 打开第二台时覆盖第一台句柄',
74
+ '· 关闭只释放自己的句柄,真正资源仍被占用',
75
+ '',
76
+ '修复',
77
+ '· shared_sdk 降级为运行库保活',
78
+ '· open/close 按序列号去重',
79
+ '· 全局覆盖前检测占用并提示',
80
+ ],
81
+ },
82
+ {
83
+ id: 'shj-port',
84
+ kind: 'mistake',
85
+ title: 'shj-web 端口菜单错位与 client 断连',
86
+ source: 'deepseek-harness',
87
+ time: '10:43',
88
+ tags: ['shj-platform', 'shj-web', '#布局'],
89
+ summary: '端口菜单被侧栏删除后,client 仍等待旧端口事件;恢复菜单并补一条回归检查。',
90
+ body: [
91
+ '现象',
92
+ '· client 一直显示连接中',
93
+ '· 端口菜单入口消失',
94
+ '',
95
+ '处理',
96
+ '· 恢复「端口管理」菜单',
97
+ '· 回归:打开菜单 → 连接 → 刷新',
98
+ ],
99
+ },
100
+ {
101
+ id: 'cursor-webview',
102
+ kind: 'project',
103
+ title: 'Cursor Webview 扩展:Memory Atlas',
104
+ source: 'codex',
105
+ time: '10:38',
106
+ tags: ['Cursor', 'Webview', '扩展'],
107
+ summary: 'Activity Bar 增加 Memory Atlas,用 Webview 加载同一套 7999 页面,版本与 DSH/Web 一致。',
108
+ body: [
109
+ '实现',
110
+ '· contributes.viewsContainers.activitybar',
111
+ '· contributes.views type=webview',
112
+ '· iframe 127.0.0.1:7999',
113
+ '',
114
+ '注意',
115
+ '· Web 服务需要先运行',
116
+ '· 不复制第二套前端',
117
+ ],
118
+ },
119
+ {
120
+ id: 'orca-panel',
121
+ kind: 'project',
122
+ title: 'Orca 右侧栏记忆面板',
123
+ source: 'deepseek-harness',
124
+ time: '10:31',
125
+ tags: ['Orca', 'Panel', 'Launcher'],
126
+ summary: 'Orca 官方 panel + terminal.sendText,打开 Memory Atlas;CSP 禁止 iframe,所以是启动器。',
127
+ body: [
128
+ '能力',
129
+ '· 右侧栏「记忆」',
130
+ '· 选择终端',
131
+ '· 执行 orca tab create / dsh-memory open',
132
+ '',
133
+ '限制',
134
+ '· panel 不能 iframe 127.0.0.1:7999',
135
+ '· 不能 fetch 本地 API',
136
+ ],
137
+ },
138
+ {
139
+ id: 'readme',
140
+ kind: 'content',
141
+ title: 'README 平台支持表',
142
+ source: 'deepseek-harness',
143
+ time: '10:24',
144
+ tags: ['README', '平台', '发布'],
145
+ summary: 'DSH / Orca / dsh-tui / Codex / Claude / Cursor / Web 一张表;能内嵌的 Webview,不能的 Launcher。',
146
+ body: [
147
+ '原则',
148
+ '· 一套 Web UI 唯一真源',
149
+ '· 版本统一',
150
+ '· 不复制 UI',
151
+ '',
152
+ '入口',
153
+ '· DSH 左栏',
154
+ '· Orca 右侧栏',
155
+ '· dsh-tui /memory',
156
+ '· Codex / Claude skill',
157
+ '· Cursor Activity Bar',
158
+ ],
159
+ },
160
+ ]
161
+
162
+ const argv = new Set(process.argv.slice(2))
163
+ const once = argv.has('--once')
164
+ const selectAllAtStart = argv.has('--select-all')
165
+ const noColor = argv.has('--no-color') || process.env.NO_COLOR
166
+ const useColor = !noColor
167
+
168
+ const sgr = (code, text) => (useColor ? code + text + C.reset : text)
169
+ const tone = (text, fg, bg = '') => (useColor ? `${fg || ''}${bg}${text}${C.reset}` : text)
170
+ const keycap = (key, label) => tone(` ${key} `, C.text, C.keyBg) + (label ? ' ' + sgr(C.dim, label) : '')
171
+
172
+ function charWidth(ch) {
173
+ const cp = ch.codePointAt(0)
174
+ if (cp === 0x200b || cp === 0xfe0f) return 0
175
+ if (
176
+ cp >= 0x1100 &&
177
+ (cp <= 0x115f ||
178
+ cp === 0x2329 ||
179
+ cp === 0x232a ||
180
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
181
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
182
+ (cp >= 0xf900 && cp <= 0xfaff) ||
183
+ (cp >= 0xfe10 && cp <= 0xfe19) ||
184
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
185
+ (cp >= 0xff00 && cp <= 0xff60) ||
186
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
187
+ (cp >= 0x1f300 && cp <= 0x1faff))
188
+ ) return 2
189
+ return 1
190
+ }
191
+
192
+ const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '')
193
+ const width = (s) => [...plain(s)].reduce((n, ch) => n + charWidth(ch), 0)
194
+
195
+ function clip(s, max) {
196
+ let out = ''
197
+ let n = 0
198
+ for (let i = 0; i < s.length;) {
199
+ if (s[i] === '\x1b') {
200
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i))
201
+ if (m) { out += m[0]; i += m[0].length; continue }
202
+ }
203
+ const ch = String.fromCodePoint(s.codePointAt(i))
204
+ const w = charWidth(ch)
205
+ if (n + w > max) break
206
+ out += ch
207
+ n += w
208
+ i += ch.length
209
+ }
210
+ return out + (useColor && out.includes('\x1b[') ? C.reset : '')
211
+ }
212
+
213
+ function pad(s, n, align = 'left') {
214
+ const gap = Math.max(0, n - width(s))
215
+ if (align === 'right') return ' '.repeat(gap) + s
216
+ if (align === 'center') return ' '.repeat(Math.floor(gap / 2)) + s + ' '.repeat(Math.ceil(gap / 2))
217
+ return s + ' '.repeat(gap)
218
+ }
219
+
220
+ function wrap(text, n) {
221
+ const lines = []
222
+ let line = ''
223
+ let used = 0
224
+ for (const ch of String(text)) {
225
+ const w = charWidth(ch)
226
+ if (used + w > n) {
227
+ lines.push(line)
228
+ line = ''
229
+ used = 0
230
+ }
231
+ line += ch
232
+ used += w
233
+ }
234
+ if (line) lines.push(line)
235
+ return lines.length ? lines : ['']
236
+ }
237
+
238
+ function panel(title, lines, w, h, accent = C.border) {
239
+ const inner = Math.max(1, w - 2)
240
+ const titleText = ' ' + title + ' '
241
+ const topFill = Math.max(0, inner - width(titleText))
242
+ const top = sgr(accent, '╭─') + sgr(accent, titleText) + sgr(accent, '─'.repeat(topFill) + '╮')
243
+ const bottom = sgr(accent, '╰' + '─'.repeat(inner) + '╯')
244
+ const body = []
245
+ for (let i = 0; i < h - 2; i++) {
246
+ const line = lines[i] || ''
247
+ body.push(sgr(C.border, '│') + pad(clip(line, inner), inner) + sgr(C.border, '│'))
248
+ }
249
+ return [top, ...body, bottom].join('\n')
250
+ }
251
+
252
+ function kindOf(card) {
253
+ return KIND[card.kind] || KIND.project
254
+ }
255
+
256
+ function queueLines(cards, state, w) {
257
+ if (!cards.length) {
258
+ return [
259
+ '',
260
+ sgr(C.green, ' ✓ 全部处理完了'),
261
+ '',
262
+ sgr(C.dim, ' 待审队列已经清空;新的卡会在下一轮对话后出现。'),
263
+ ]
264
+ }
265
+ const lines = []
266
+ const selectedCount = cards.filter((c) => state.marked.has(c.id)).length
267
+ const allSelected = cards.length > 0 && selectedCount === cards.length
268
+ lines.push(
269
+ ` ${allSelected ? sgr(C.ember, '▣') : sgr(C.faint, '☐')} ${sgr(C.dim, allSelected ? '反选' : '全选')} ${cards.length}` +
270
+ ` ${selectedCount ? sgr(C.amber, '已选 ' + selectedCount) : sgr(C.faint, '已选 0')}` +
271
+ ` ${keycap('s', allSelected ? '反选' : '全选')}`,
272
+ )
273
+ lines.push('')
274
+ cards.forEach((card, i) => {
275
+ const k = kindOf(card)
276
+ const selected = i === state.index
277
+ const marked = state.marked.has(card.id)
278
+ const marker = marked ? sgr(C.amber, '◉') : sgr(C.faint, '○')
279
+ const lead = selected ? sgr(C.ember, '▌') : ' '
280
+ const badge = tone(' ' + k.mark + ' ', k.color, selected ? C.selectedBg : '')
281
+ let line = `${lead}${marker} ${badge} ${sgr(k.color, '[' + k.label + ']')} ${sgr(C.text, card.title)}`
282
+ line = clip(line, w)
283
+ if (selected) line = tone(pad(line, w), C.text, C.selectedBg)
284
+ lines.push(line)
285
+ const meta = ` ${sgr(C.faint, card.source)} ${sgr(C.faint, '·')} ${sgr(C.dim, card.time)} ${sgr(C.amber, '待审')}`
286
+ let metaLine = clip(meta, w)
287
+ if (selected) metaLine = tone(pad(metaLine, w), '', C.selectedBg)
288
+ lines.push(metaLine)
289
+ lines.push('')
290
+ })
291
+ return lines
292
+ }
293
+
294
+ function previewLines(card, w) {
295
+ if (!card) return [sgr(C.dim, '没有可预览的卡片')]
296
+ const k = kindOf(card)
297
+ const inner = Math.max(8, w - 2)
298
+ const lines = []
299
+ lines.push([
300
+ tone(' ' + k.mark + ' ' + k.label + ' ', k.color, C.chipBg),
301
+ tone(' 待审核 ', C.amber, C.chipBg),
302
+ sgr(C.faint, ' ' + card.time + ' ' + card.source),
303
+ ].join(''))
304
+ lines.push('')
305
+ lines.push(sgr(C.text, card.title))
306
+ lines.push('')
307
+ const tabs = [
308
+ tone(' 判词 ', C.ember, C.chipBg),
309
+ sgr(C.dim, ' 原文 '),
310
+ sgr(C.dim, ' 相关 '),
311
+ ].join(' ')
312
+ lines.push(tabs)
313
+ lines.push('')
314
+ for (const part of wrap('摘要 ' + card.summary, inner)) {
315
+ lines.push(tone(part, C.text, C.calloutBg))
316
+ }
317
+ lines.push('')
318
+ lines.push(card.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' '))
319
+ lines.push('')
320
+ lines.push(sgr(C.border, '─'.repeat(inner)))
321
+ lines.push('')
322
+ const headings = /^(变更结论|根因|现象|处理|修复|边界|实现|限制|能力|原则|入口|注意|背景|验证)$/
323
+ for (const line of card.body) {
324
+ if (!line) { lines.push(''); continue }
325
+ if (headings.test(line)) lines.push(sgr('\x1b[1m' + C.ember, line))
326
+ else lines.push(line.startsWith('·') ? sgr(C.dim, line) : sgr(C.text, line))
327
+ }
328
+ const related = CARDS.filter((c) => c.id !== card.id).slice(0, 2)
329
+ if (related.length) {
330
+ lines.push('')
331
+ lines.push(sgr(C.dim, '相关记忆'))
332
+ for (const r of related) {
333
+ lines.push(`${sgr(kindOf(r).color, '→')} ${sgr(C.dim, r.title)}`)
334
+ }
335
+ }
336
+ lines.push('')
337
+ lines.push([keycap('a', '批准'), keycap('r', '驳回'), keycap('Space', '多选')].join(' '))
338
+ return lines
339
+ }
340
+
341
+ function frame(state) {
342
+ const w = Math.max(64, Math.min(useColor ? 130 : 110, process.stdout.columns || 110))
343
+ const rows = Math.max(22, process.stdout.rows || 34)
344
+ const inner = w - 2
345
+ const allCards = state.cards
346
+ const cards = state.kind && state.kind !== 'all' ? allCards.filter((c) => c.kind === state.kind) : allCards
347
+ const selected = cards[state.index]
348
+ const marked = state.marked.size
349
+ const out = []
350
+
351
+ const title = `${sgr(C.ember, '●')} ${sgr(C.ember, 'MEMORY ATLAS')} ${sgr(C.faint, 'LOCAL MEMORY')} ${sgr(C.faint, '/')} ${sgr(C.text, '待审')}`
352
+ const bar = sgr(C.ember, '▰▰▰') + sgr(C.faint, '▱▱')
353
+ const stat = `${sgr(C.amber, String(cards.length))} ${sgr(C.dim, '待审')}${marked ? ` ${sgr(C.amber, '已选 ' + marked)}` : ''} ${bar} ${keycap('?', '帮助')} ${keycap('q', '退出')}`
354
+ const titleRoom = Math.max(12, inner - width(stat) - 1)
355
+ out.push(pad(clip(title, titleRoom), titleRoom) + ' ' + stat)
356
+ out.push(sgr(C.border, '─'.repeat(w)))
357
+
358
+ const typeLine = sgr(C.faint, '类型 ') + [
359
+ ['全部', 'all'],
360
+ ['项目', 'project'],
361
+ ['知识', 'knowledge'],
362
+ ['教训', 'mistake'],
363
+ ['内容', 'content'],
364
+ ['工具', 'tool'],
365
+ ['提示词', 'prompt'],
366
+ ].map(([label, key], i) => {
367
+ const active = (state.kind || 'all') === key
368
+ const count = key === 'all' ? CARDS.length : CARDS.filter((c) => c.kind === key).length
369
+ return tone(` ${active ? '●' : '○'} ${label} ${count} `, active ? C.text : C.dim, active ? C.chipBg : '')
370
+ }).join(' ')
371
+ out.push(' ' + clip(typeLine, inner - 1))
372
+ out.push('')
373
+
374
+ const chrome = 7
375
+ const panelH = Math.max(10, rows - chrome)
376
+ const narrow = w < 96
377
+ const queueW = narrow ? w : Math.max(42, Math.floor((w - 3) * 0.4))
378
+ const previewW = w - queueW - 3
379
+
380
+ if (state.help) {
381
+ const help = [
382
+ sgr(C.ember, '键盘'),
383
+ '',
384
+ ` ${keycap('↑↓')} ${keycap('j k')} 移动选择`,
385
+ ` ${keycap('Space')} 多选 / 取消`,
386
+ ` ${keycap('a')} 批准选中或已选`,
387
+ ` ${keycap('r')} 驳回选中或已选`,
388
+ ` ${keycap('Enter')} 窄屏打开详情`,
389
+ ` ${keycap('?')} 关闭帮助`,
390
+ ` ${keycap('q')} 退出演示`,
391
+ '',
392
+ sgr(C.dim, '所有操作只作用于示例数据,不会写入真实 vault。'),
393
+ ]
394
+ out.push(panel('帮助', help, w, panelH, C.ember))
395
+ } else if (narrow && state.view === 'detail') {
396
+ out.push(panel('判词', previewLines(selected, inner - 2), w, panelH, C.ember))
397
+ } else if (narrow) {
398
+ out.push(panel(`待审 ${cards.length}`, queueLines(cards, state, inner - 2), w, panelH, C.border))
399
+ } else {
400
+ const q = panel(`队列 ${cards.length}${marked ? ' ◉' + marked : ''}`, queueLines(cards, state, queueW - 2), queueW, panelH)
401
+ const p = panel('判词', previewLines(selected, previewW - 2), previewW, panelH, C.ember)
402
+ const ql = q.split('\n')
403
+ const pl = p.split('\n')
404
+ for (let i = 0; i < Math.max(ql.length, pl.length); i++) {
405
+ out.push((ql[i] || ' '.repeat(queueW)) + ' ' + (pl[i] || ' '.repeat(previewW)))
406
+ }
407
+ }
408
+
409
+ const toast = state.toast ? ` ${sgr(state.toast.ok ? C.green : C.red, state.toast.text)}` : ''
410
+ const keys = marked
411
+ ? [keycap('a', '批准 ' + marked), keycap('r', '驳回'), keycap('s', '反选'), keycap('Esc', '清空')].join(' ')
412
+ : [keycap('↑↓', '移动'), keycap('Space', '多选'), keycap('s', '全选'), keycap('1-7', '筛选'), keycap('?', '帮助')].join(' ')
413
+ out.push(' ' + clip(toast || keys, inner - 1))
414
+ return out.join('\n')
415
+ }
416
+
417
+ function draw(state) {
418
+ if (once) {
419
+ process.stdout.write(frame(state) + '\n')
420
+ return
421
+ }
422
+ process.stdout.write('\x1b[H' + frame(state))
423
+ process.stdout.write('\x1b[J')
424
+ }
425
+
426
+ function main() {
427
+ const state = {
428
+ cards: CARDS.slice(),
429
+ kind: 'all',
430
+ index: 0,
431
+ marked: selectAllAtStart ? new Set(CARDS.map((c) => c.id)) : new Set(),
432
+ view: 'list',
433
+ help: false,
434
+ toast: null,
435
+ }
436
+
437
+ if (once) {
438
+ draw(state)
439
+ return
440
+ }
441
+
442
+ if (!process.stdout.isTTY) {
443
+ process.stderr.write('请在有 TTY 的终端里运行;只预览一帧可加 --once\n')
444
+ process.exitCode = 1
445
+ return
446
+ }
447
+
448
+ readline.emitKeypressEvents(process.stdin)
449
+ process.stdin.setRawMode(true)
450
+ process.stdout.write('\x1b[?1049h\x1b[?25l')
451
+ draw(state)
452
+
453
+ const cleanup = () => {
454
+ process.stdout.write('\x1b[?25h\x1b[?1049l')
455
+ try { process.stdin.setRawMode(false) } catch {}
456
+ process.stdin.pause()
457
+ }
458
+
459
+ const toastTimer = { id: null }
460
+ const showToast = (text, ok = true) => {
461
+ state.toast = { text, ok }
462
+ if (toastTimer.id) clearTimeout(toastTimer.id)
463
+ toastTimer.id = setTimeout(() => { state.toast = null; draw(state) }, 1800)
464
+ }
465
+
466
+ const removeSelected = (decision) => {
467
+ if (!state.cards.length) return
468
+ const visible = state.kind && state.kind !== 'all' ? state.cards.filter((c) => c.kind === state.kind) : state.cards
469
+ const targets = state.marked.size > 0
470
+ ? visible.filter((c) => state.marked.has(c.id))
471
+ : (visible[state.index] ? [visible[state.index]] : [])
472
+ if (!targets.length) return
473
+ const ids = new Set(targets.map((c) => c.id))
474
+ state.cards = state.cards.filter((c) => !ids.has(c.id))
475
+ state.index = 0
476
+ state.marked.clear()
477
+ showToast(`${decision === 'a' ? '✓ 已批准' : '✕ 已驳回'} ${targets.length} 张:${targets[0].title}`, decision === 'a')
478
+ }
479
+
480
+ const onKey = (str, key = {}) => {
481
+ if (key.ctrl && key.name === 'c') { cleanup(); process.exit(0) }
482
+ if (state.help) {
483
+ if (key.name === 'escape' || str === '?' || str === 'q') {
484
+ state.help = false
485
+ draw(state)
486
+ }
487
+ return
488
+ }
489
+ if (str === 'q') { cleanup(); process.exit(0) }
490
+ if (str === '?') { state.help = true; draw(state); return }
491
+ if (/^[1-7]$/.test(str)) {
492
+ state.kind = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt'][Number(str) - 1]
493
+ state.index = 0
494
+ state.marked.clear()
495
+ draw(state)
496
+ return
497
+ }
498
+ const visible = state.kind && state.kind !== 'all' ? state.cards.filter((c) => c.kind === state.kind) : state.cards
499
+ if (str === 's' || (key.ctrl && key.name === 'a')) {
500
+ const all = visible.length > 0 && visible.every((c) => state.marked.has(c.id))
501
+ if (all) visible.forEach((c) => state.marked.delete(c.id))
502
+ else visible.forEach((c) => state.marked.add(c.id))
503
+ draw(state)
504
+ return
505
+ }
506
+ if (key.name === 'up' || str === 'k') { state.index = Math.max(0, state.index - 1); draw(state); return }
507
+ if (key.name === 'down' || str === 'j') { state.index = Math.min(Math.max(0, visible.length - 1), state.index + 1); draw(state); return }
508
+ if (key.name === 'return' || key.name === 'enter') {
509
+ if (process.stdout.columns < 96) state.view = state.view === 'detail' ? 'list' : 'detail'
510
+ draw(state)
511
+ return
512
+ }
513
+ if (key.name === 'escape') {
514
+ if (state.marked.size) state.marked.clear()
515
+ else state.view = 'list'
516
+ draw(state)
517
+ return
518
+ }
519
+ if (key.name === 'space' || str === ' ') {
520
+ const card = visible[state.index]
521
+ if (card) {
522
+ if (state.marked.has(card.id)) state.marked.delete(card.id)
523
+ else state.marked.add(card.id)
524
+ draw(state)
525
+ }
526
+ return
527
+ }
528
+ if (str === 'a' || str === 'r') { removeSelected(str); draw(state) }
529
+ }
530
+
531
+ process.stdin.on('keypress', onKey)
532
+ process.stdout.on('resize', () => draw(state))
533
+ }
534
+
535
+ main()