@getlore/cli 0.2.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.
Files changed (148) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +80 -0
  3. package/dist/cli/colors.d.ts +48 -0
  4. package/dist/cli/colors.js +48 -0
  5. package/dist/cli/commands/ask.d.ts +7 -0
  6. package/dist/cli/commands/ask.js +97 -0
  7. package/dist/cli/commands/auth.d.ts +10 -0
  8. package/dist/cli/commands/auth.js +484 -0
  9. package/dist/cli/commands/daemon.d.ts +22 -0
  10. package/dist/cli/commands/daemon.js +244 -0
  11. package/dist/cli/commands/docs.d.ts +7 -0
  12. package/dist/cli/commands/docs.js +188 -0
  13. package/dist/cli/commands/extensions.d.ts +7 -0
  14. package/dist/cli/commands/extensions.js +204 -0
  15. package/dist/cli/commands/misc.d.ts +7 -0
  16. package/dist/cli/commands/misc.js +172 -0
  17. package/dist/cli/commands/pending.d.ts +7 -0
  18. package/dist/cli/commands/pending.js +63 -0
  19. package/dist/cli/commands/projects.d.ts +7 -0
  20. package/dist/cli/commands/projects.js +136 -0
  21. package/dist/cli/commands/search.d.ts +7 -0
  22. package/dist/cli/commands/search.js +102 -0
  23. package/dist/cli/commands/skills.d.ts +24 -0
  24. package/dist/cli/commands/skills.js +447 -0
  25. package/dist/cli/commands/sources.d.ts +7 -0
  26. package/dist/cli/commands/sources.js +121 -0
  27. package/dist/cli/commands/sync.d.ts +31 -0
  28. package/dist/cli/commands/sync.js +768 -0
  29. package/dist/cli/helpers.d.ts +30 -0
  30. package/dist/cli/helpers.js +119 -0
  31. package/dist/core/auth.d.ts +62 -0
  32. package/dist/core/auth.js +330 -0
  33. package/dist/core/config.d.ts +41 -0
  34. package/dist/core/config.js +96 -0
  35. package/dist/core/data-repo.d.ts +31 -0
  36. package/dist/core/data-repo.js +146 -0
  37. package/dist/core/embedder.d.ts +22 -0
  38. package/dist/core/embedder.js +104 -0
  39. package/dist/core/git.d.ts +37 -0
  40. package/dist/core/git.js +140 -0
  41. package/dist/core/index.d.ts +4 -0
  42. package/dist/core/index.js +5 -0
  43. package/dist/core/insight-extractor.d.ts +26 -0
  44. package/dist/core/insight-extractor.js +114 -0
  45. package/dist/core/local-search.d.ts +43 -0
  46. package/dist/core/local-search.js +221 -0
  47. package/dist/core/themes.d.ts +15 -0
  48. package/dist/core/themes.js +77 -0
  49. package/dist/core/types.d.ts +177 -0
  50. package/dist/core/types.js +9 -0
  51. package/dist/core/user-settings.d.ts +15 -0
  52. package/dist/core/user-settings.js +42 -0
  53. package/dist/core/vector-store-lance.d.ts +98 -0
  54. package/dist/core/vector-store-lance.js +384 -0
  55. package/dist/core/vector-store-supabase.d.ts +89 -0
  56. package/dist/core/vector-store-supabase.js +295 -0
  57. package/dist/core/vector-store.d.ts +131 -0
  58. package/dist/core/vector-store.js +503 -0
  59. package/dist/daemon-runner.d.ts +8 -0
  60. package/dist/daemon-runner.js +246 -0
  61. package/dist/extensions/config.d.ts +22 -0
  62. package/dist/extensions/config.js +102 -0
  63. package/dist/extensions/proposals.d.ts +30 -0
  64. package/dist/extensions/proposals.js +178 -0
  65. package/dist/extensions/registry.d.ts +35 -0
  66. package/dist/extensions/registry.js +309 -0
  67. package/dist/extensions/sandbox.d.ts +16 -0
  68. package/dist/extensions/sandbox.js +17 -0
  69. package/dist/extensions/types.d.ts +114 -0
  70. package/dist/extensions/types.js +4 -0
  71. package/dist/extensions/worker.d.ts +1 -0
  72. package/dist/extensions/worker.js +49 -0
  73. package/dist/index.d.ts +17 -0
  74. package/dist/index.js +105 -0
  75. package/dist/mcp/handlers/archive-project.d.ts +51 -0
  76. package/dist/mcp/handlers/archive-project.js +112 -0
  77. package/dist/mcp/handlers/get-quotes.d.ts +27 -0
  78. package/dist/mcp/handlers/get-quotes.js +61 -0
  79. package/dist/mcp/handlers/get-source.d.ts +9 -0
  80. package/dist/mcp/handlers/get-source.js +40 -0
  81. package/dist/mcp/handlers/ingest.d.ts +25 -0
  82. package/dist/mcp/handlers/ingest.js +305 -0
  83. package/dist/mcp/handlers/list-projects.d.ts +4 -0
  84. package/dist/mcp/handlers/list-projects.js +16 -0
  85. package/dist/mcp/handlers/list-sources.d.ts +11 -0
  86. package/dist/mcp/handlers/list-sources.js +20 -0
  87. package/dist/mcp/handlers/research-agent.d.ts +21 -0
  88. package/dist/mcp/handlers/research-agent.js +369 -0
  89. package/dist/mcp/handlers/research.d.ts +22 -0
  90. package/dist/mcp/handlers/research.js +225 -0
  91. package/dist/mcp/handlers/retain.d.ts +18 -0
  92. package/dist/mcp/handlers/retain.js +92 -0
  93. package/dist/mcp/handlers/search.d.ts +52 -0
  94. package/dist/mcp/handlers/search.js +145 -0
  95. package/dist/mcp/handlers/sync.d.ts +47 -0
  96. package/dist/mcp/handlers/sync.js +211 -0
  97. package/dist/mcp/server.d.ts +10 -0
  98. package/dist/mcp/server.js +268 -0
  99. package/dist/mcp/tools.d.ts +16 -0
  100. package/dist/mcp/tools.js +297 -0
  101. package/dist/sync/config.d.ts +26 -0
  102. package/dist/sync/config.js +140 -0
  103. package/dist/sync/discover.d.ts +51 -0
  104. package/dist/sync/discover.js +190 -0
  105. package/dist/sync/index.d.ts +11 -0
  106. package/dist/sync/index.js +11 -0
  107. package/dist/sync/process.d.ts +50 -0
  108. package/dist/sync/process.js +285 -0
  109. package/dist/sync/processors.d.ts +24 -0
  110. package/dist/sync/processors.js +351 -0
  111. package/dist/tui/browse-handlers-ask.d.ts +30 -0
  112. package/dist/tui/browse-handlers-ask.js +372 -0
  113. package/dist/tui/browse-handlers-autocomplete.d.ts +49 -0
  114. package/dist/tui/browse-handlers-autocomplete.js +270 -0
  115. package/dist/tui/browse-handlers-extensions.d.ts +18 -0
  116. package/dist/tui/browse-handlers-extensions.js +107 -0
  117. package/dist/tui/browse-handlers-pending.d.ts +22 -0
  118. package/dist/tui/browse-handlers-pending.js +100 -0
  119. package/dist/tui/browse-handlers-research.d.ts +32 -0
  120. package/dist/tui/browse-handlers-research.js +363 -0
  121. package/dist/tui/browse-handlers-tools.d.ts +42 -0
  122. package/dist/tui/browse-handlers-tools.js +289 -0
  123. package/dist/tui/browse-handlers.d.ts +239 -0
  124. package/dist/tui/browse-handlers.js +1944 -0
  125. package/dist/tui/browse-render-extensions.d.ts +14 -0
  126. package/dist/tui/browse-render-extensions.js +114 -0
  127. package/dist/tui/browse-render-tools.d.ts +18 -0
  128. package/dist/tui/browse-render-tools.js +259 -0
  129. package/dist/tui/browse-render.d.ts +51 -0
  130. package/dist/tui/browse-render.js +599 -0
  131. package/dist/tui/browse-types.d.ts +142 -0
  132. package/dist/tui/browse-types.js +70 -0
  133. package/dist/tui/browse-ui.d.ts +10 -0
  134. package/dist/tui/browse-ui.js +432 -0
  135. package/dist/tui/browse.d.ts +17 -0
  136. package/dist/tui/browse.js +625 -0
  137. package/dist/tui/markdown.d.ts +22 -0
  138. package/dist/tui/markdown.js +223 -0
  139. package/package.json +71 -0
  140. package/plugins/claude-code/.claude-plugin/plugin.json +10 -0
  141. package/plugins/claude-code/.mcp.json +6 -0
  142. package/plugins/claude-code/skills/lore/SKILL.md +63 -0
  143. package/plugins/codex/SKILL.md +36 -0
  144. package/plugins/codex/agents/openai.yaml +10 -0
  145. package/plugins/gemini/GEMINI.md +31 -0
  146. package/plugins/gemini/gemini-extension.json +11 -0
  147. package/skills/generic-agent.md +99 -0
  148. package/skills/openclaw.md +67 -0
@@ -0,0 +1,1944 @@
1
+ /**
2
+ * Event handlers and operations for the Lore Document Browser TUI
3
+ *
4
+ * Contains navigation, search, editor integration, and mode switching.
5
+ */
6
+ import { spawn } from 'child_process';
7
+ import { writeFileSync, unlinkSync } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import path from 'path';
10
+ import { formatDate, markdownToBlessed, renderFullView, renderList, renderPreview, updateStatus, buildListItems, getSelectedSource, } from './browse-render.js';
11
+ import { getSourceById, searchSources, getProjectStats, getAllSources, deleteSource, updateSourceProjects, updateSourceTitle, updateSourceContentType } from '../core/vector-store.js';
12
+ import { generateEmbedding } from '../core/embedder.js';
13
+ import { searchLocalFiles } from '../core/local-search.js';
14
+ import { gitCommitAndPush, deleteFileAndCommit } from '../core/git.js';
15
+ /**
16
+ * Helper to re-render ask/research pane when returning from pickers
17
+ * Exported so browse.ts can use it for autocomplete direct selection
18
+ */
19
+ export function renderReturnToAskOrResearch(state, ui, mode) {
20
+ const filters = [];
21
+ if (state.currentProject)
22
+ filters.push(`project: ${state.currentProject}`);
23
+ if (state.currentContentType)
24
+ filters.push(`type: ${state.currentContentType}`);
25
+ const filterInfo = filters.length > 0
26
+ ? `{yellow-fg}Scope: ${filters.join(', ')}{/yellow-fg}`
27
+ : '{blue-fg}No filters{/blue-fg}';
28
+ const footerNote = filters.length > 0
29
+ ? `{yellow-fg}${filters.join(', ')}{/yellow-fg}`
30
+ : '{blue-fg}all sources{/blue-fg}';
31
+ if (mode === 'ask') {
32
+ const lines = [
33
+ `${filterInfo} {blue-fg}│{/blue-fg} {white-fg}/help{/white-fg} for commands {blue-fg}│{/blue-fg} {white-fg}/new{/white-fg} to start fresh`,
34
+ '',
35
+ ];
36
+ if (state.askHistory.length > 0) {
37
+ for (const msg of state.askHistory) {
38
+ if (msg.role === 'user') {
39
+ lines.push(`{cyan-fg}You:{/cyan-fg} ${msg.content}`);
40
+ }
41
+ else {
42
+ const escaped = msg.content.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
43
+ lines.push(`{green-fg}Assistant:{/green-fg}`);
44
+ lines.push(escaped);
45
+ }
46
+ lines.push('');
47
+ }
48
+ }
49
+ else {
50
+ lines.push('{blue-fg}Ask a question about your knowledge base...{/blue-fg}');
51
+ }
52
+ ui.askPane.setContent(lines.join('\n'));
53
+ const historyNote = state.askHistory.length > 0 ? `${state.askHistory.length / 2} Q&A │ ` : '';
54
+ ui.footer.setContent(` ${historyNote}Enter: Send │ Esc: Back │ Scope: ${footerNote}`);
55
+ }
56
+ else {
57
+ const lines = [
58
+ `${filterInfo} {blue-fg}│{/blue-fg} {white-fg}/help{/white-fg} for commands {blue-fg}│{/blue-fg} {white-fg}/new{/white-fg} to start fresh`,
59
+ '',
60
+ ];
61
+ if (state.researchHistory.length > 0) {
62
+ for (const item of state.researchHistory) {
63
+ lines.push(`{cyan-fg}Research:{/cyan-fg} ${item.query}`);
64
+ lines.push('');
65
+ const escaped = item.summary.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
66
+ lines.push(escaped);
67
+ lines.push('');
68
+ lines.push('{blue-fg}───────────────────────────────────────{/blue-fg}');
69
+ lines.push('');
70
+ }
71
+ }
72
+ else {
73
+ lines.push('{blue-fg}Enter a research task to begin comprehensive analysis...{/blue-fg}');
74
+ lines.push('');
75
+ lines.push('{blue-fg}The research agent will iteratively explore sources,{/blue-fg}');
76
+ lines.push('{blue-fg}cross-reference findings, and synthesize results.{/blue-fg}');
77
+ }
78
+ ui.askPane.setContent(lines.join('\n'));
79
+ const historyNote = state.researchHistory.length > 0 ? `${state.researchHistory.length} tasks │ ` : '';
80
+ ui.footer.setContent(` ${historyNote}Enter: Research │ Esc: Back │ Scope: ${footerNote}`);
81
+ }
82
+ }
83
+ /**
84
+ * Load full content for the selected document
85
+ */
86
+ export async function loadFullContent(state, ui, dbPath, sourcesDir) {
87
+ if (state.filtered.length === 0)
88
+ return;
89
+ // Get selected source (handles both grouped and flat view)
90
+ const source = getSelectedSource(state);
91
+ if (!source)
92
+ return;
93
+ // Try to load from disk first
94
+ const contentPath = path.join(sourcesDir, source.id, 'content.md');
95
+ try {
96
+ const { readFile } = await import('fs/promises');
97
+ state.fullContent = await readFile(contentPath, 'utf-8');
98
+ }
99
+ catch {
100
+ // Fall back to database source details
101
+ const details = await getSourceById(dbPath, source.id);
102
+ if (details) {
103
+ state.fullContent = [
104
+ `# ${details.title}`,
105
+ '',
106
+ `**Type:** ${details.source_type} · ${details.content_type}`,
107
+ `**Date:** ${formatDate(details.created_at)}`,
108
+ `**Projects:** ${details.projects.join(', ') || '(none)'}`,
109
+ '',
110
+ '## Summary',
111
+ details.summary,
112
+ '',
113
+ ].join('\n');
114
+ if (details.themes && details.themes.length > 0) {
115
+ state.fullContent += '## Themes\n';
116
+ for (const theme of details.themes) {
117
+ state.fullContent += `- **${theme.name}**`;
118
+ if (theme.summary)
119
+ state.fullContent += `: ${theme.summary}`;
120
+ state.fullContent += '\n';
121
+ }
122
+ state.fullContent += '\n';
123
+ }
124
+ if (details.quotes && details.quotes.length > 0) {
125
+ state.fullContent += '## Key Quotes\n';
126
+ for (const quote of details.quotes.slice(0, 10)) {
127
+ const speaker = quote.speaker === 'user' ? '[You]' : `[${quote.speaker_name || 'Participant'}]`;
128
+ state.fullContent += `> ${speaker} "${quote.text}"\n\n`;
129
+ }
130
+ }
131
+ }
132
+ else {
133
+ state.fullContent = `Could not load content for ${source.title}`;
134
+ }
135
+ }
136
+ // Store raw lines for searching
137
+ state.fullContentLinesRaw = state.fullContent.split('\n');
138
+ // Convert markdown to blessed tags (no ANSI codes)
139
+ const rendered = markdownToBlessed(state.fullContent);
140
+ state.fullContentLines = rendered.split('\n');
141
+ // Reset search state
142
+ state.docSearchPattern = '';
143
+ state.docSearchMatches = [];
144
+ state.docSearchCurrentIdx = 0;
145
+ state.scrollOffset = 0;
146
+ renderFullView(ui, state);
147
+ }
148
+ /**
149
+ * Enter full view mode
150
+ */
151
+ export async function enterFullView(state, ui, dbPath, sourcesDir) {
152
+ state.mode = 'fullview';
153
+ ui.listPane.hide();
154
+ ui.previewPane.hide();
155
+ ui.fullViewPane.show();
156
+ // Show loading state
157
+ ui.fullViewContent.setContent('{blue-fg}Loading...{/blue-fg}');
158
+ ui.screen.render();
159
+ // Load content and render
160
+ await loadFullContent(state, ui, dbPath, sourcesDir);
161
+ ui.footer.setContent(' j/k Scroll │ / Search │ y Copy │ e Edit │ Esc Back');
162
+ ui.screen.render();
163
+ }
164
+ /**
165
+ * Exit full view mode
166
+ */
167
+ export function exitFullView(state, ui) {
168
+ state.mode = 'list';
169
+ ui.fullViewPane.hide();
170
+ ui.listPane.show();
171
+ ui.previewPane.show();
172
+ ui.footer.setContent(' j/k Nav │ / Search │ a Ask │ R Research │ p Proj │ c Type │ m Move │ i Edit │ Esc Quit │ ? Help');
173
+ ui.screen.render();
174
+ }
175
+ /**
176
+ * Enter semantic/hybrid search mode
177
+ */
178
+ export function enterSearch(state, ui) {
179
+ state.mode = 'search';
180
+ state.searchMode = 'hybrid';
181
+ ui.searchInput.show();
182
+ ui.searchInput.setValue('/');
183
+ ui.searchInput.focus();
184
+ ui.screen.render();
185
+ }
186
+ /**
187
+ * Enter regex search mode
188
+ */
189
+ export function enterRegexSearch(state, ui) {
190
+ state.mode = 'regex-search';
191
+ state.searchMode = 'regex';
192
+ ui.regexInput.show();
193
+ ui.regexInput.setValue(':');
194
+ ui.regexInput.focus();
195
+ ui.screen.render();
196
+ }
197
+ /**
198
+ * Exit search mode
199
+ */
200
+ export function exitSearch(state, ui) {
201
+ state.mode = 'list';
202
+ ui.searchInput.hide();
203
+ ui.regexInput.hide();
204
+ ui.docSearchInput.hide();
205
+ ui.listContent.focus();
206
+ ui.screen.render();
207
+ }
208
+ /**
209
+ * Enter document search mode (within full view)
210
+ */
211
+ export function enterDocSearch(state, ui) {
212
+ state.mode = 'doc-search';
213
+ ui.docSearchInput.show();
214
+ ui.docSearchInput.setValue('/');
215
+ ui.docSearchInput.focus();
216
+ ui.screen.render();
217
+ }
218
+ /**
219
+ * Exit document search mode
220
+ */
221
+ export function exitDocSearch(state, ui, clearPattern = false) {
222
+ state.mode = 'fullview';
223
+ ui.docSearchInput.hide();
224
+ if (clearPattern) {
225
+ state.docSearchPattern = '';
226
+ state.docSearchMatches = [];
227
+ state.docSearchCurrentIdx = 0;
228
+ }
229
+ renderFullView(ui, state);
230
+ }
231
+ /**
232
+ * Apply document search pattern
233
+ */
234
+ export function applyDocSearch(state, ui, pattern) {
235
+ state.docSearchPattern = pattern;
236
+ state.docSearchMatches = [];
237
+ state.docSearchCurrentIdx = 0;
238
+ if (!pattern) {
239
+ renderFullView(ui, state);
240
+ return;
241
+ }
242
+ try {
243
+ const regex = new RegExp(pattern, 'gi');
244
+ // Find all lines that match
245
+ for (let i = 0; i < state.fullContentLinesRaw.length; i++) {
246
+ if (regex.test(state.fullContentLinesRaw[i])) {
247
+ state.docSearchMatches.push(i);
248
+ }
249
+ regex.lastIndex = 0; // Reset for next test
250
+ }
251
+ // Jump to first match if found
252
+ if (state.docSearchMatches.length > 0) {
253
+ scrollToMatch(state, ui, 0);
254
+ }
255
+ else {
256
+ renderFullView(ui, state);
257
+ }
258
+ }
259
+ catch {
260
+ // Invalid regex
261
+ state.docSearchMatches = [];
262
+ renderFullView(ui, state);
263
+ }
264
+ }
265
+ /**
266
+ * Scroll to a specific match
267
+ */
268
+ export function scrollToMatch(state, ui, matchIdx) {
269
+ if (state.docSearchMatches.length === 0)
270
+ return;
271
+ state.docSearchCurrentIdx = matchIdx;
272
+ const matchLine = state.docSearchMatches[matchIdx];
273
+ const height = ui.fullViewContent.height - 1;
274
+ // Center the match on screen
275
+ state.scrollOffset = Math.max(0, matchLine - Math.floor(height / 2));
276
+ state.scrollOffset = Math.min(state.scrollOffset, Math.max(0, state.fullContentLines.length - height));
277
+ renderFullView(ui, state);
278
+ }
279
+ /**
280
+ * Go to next match
281
+ */
282
+ export function nextMatch(state, ui) {
283
+ if (state.docSearchMatches.length === 0)
284
+ return;
285
+ const nextIdx = (state.docSearchCurrentIdx + 1) % state.docSearchMatches.length;
286
+ scrollToMatch(state, ui, nextIdx);
287
+ }
288
+ /**
289
+ * Go to previous match
290
+ */
291
+ export function prevMatch(state, ui) {
292
+ if (state.docSearchMatches.length === 0)
293
+ return;
294
+ const prevIdx = (state.docSearchCurrentIdx - 1 + state.docSearchMatches.length) % state.docSearchMatches.length;
295
+ scrollToMatch(state, ui, prevIdx);
296
+ }
297
+ /**
298
+ * Apply search filter
299
+ */
300
+ export async function applyFilter(state, ui, query, filterMode, dbPath, dataDir, project, sourceType) {
301
+ state.searchQuery = query;
302
+ state.searchMode = filterMode;
303
+ if (!query) {
304
+ state.filtered = [...state.sources];
305
+ state.searchMode = 'hybrid'; // Reset mode when clearing
306
+ }
307
+ else if (filterMode === 'regex') {
308
+ // Use local regex search
309
+ ui.statusBar.setContent(` Regex search "${query}"...`);
310
+ ui.screen.render();
311
+ try {
312
+ const results = await searchLocalFiles(dataDir, query, {
313
+ maxTotalResults: 50,
314
+ maxMatchesPerFile: 5,
315
+ });
316
+ // Convert to SourceItem format, respecting project filter
317
+ const sourceIds = results.map(r => r.source_id);
318
+ state.filtered = state.sources
319
+ .filter(s => {
320
+ // Must match regex result
321
+ if (!sourceIds.includes(s.id))
322
+ return false;
323
+ // Must match project filter if set
324
+ if (project && !s.projects.includes(project))
325
+ return false;
326
+ return true;
327
+ })
328
+ .map(s => {
329
+ const matchResult = results.find(r => r.source_id === s.id);
330
+ return {
331
+ ...s,
332
+ score: matchResult ? matchResult.matches.length / 10 : 0,
333
+ };
334
+ })
335
+ .sort((a, b) => (b.score || 0) - (a.score || 0));
336
+ if (state.filtered.length === 0) {
337
+ // No matches found
338
+ const projectNote = project ? ` in project "${project}"` : '';
339
+ ui.statusBar.setContent(` No regex matches for "${query}"${projectNote}`);
340
+ ui.screen.render();
341
+ }
342
+ }
343
+ catch (error) {
344
+ ui.statusBar.setContent(` Regex error: ${error}`);
345
+ ui.screen.render();
346
+ state.filtered = [];
347
+ }
348
+ }
349
+ else {
350
+ // Use hybrid/semantic search
351
+ ui.statusBar.setContent(` Searching "${query}"...`);
352
+ ui.screen.render();
353
+ try {
354
+ // Use hybrid search
355
+ const queryVector = await generateEmbedding(query);
356
+ const results = await searchSources(dbPath, queryVector, {
357
+ limit: 50,
358
+ project,
359
+ source_type: sourceType,
360
+ mode: filterMode,
361
+ queryText: query,
362
+ });
363
+ // Convert search results to SourceItem format, sorted by score
364
+ state.filtered = results
365
+ .sort((a, b) => b.score - a.score) // Highest score first
366
+ .map(r => ({
367
+ id: r.id,
368
+ title: r.title,
369
+ source_type: r.source_type,
370
+ content_type: r.content_type,
371
+ projects: r.projects,
372
+ created_at: r.created_at,
373
+ summary: r.summary,
374
+ score: r.score,
375
+ }));
376
+ }
377
+ catch (error) {
378
+ // Fall back to text filter on error
379
+ ui.statusBar.setContent(` Search error, using text filter...`);
380
+ ui.screen.render();
381
+ const lower = query.toLowerCase();
382
+ state.filtered = state.sources.filter(s => s.title.toLowerCase().includes(lower) ||
383
+ s.summary.toLowerCase().includes(lower) ||
384
+ s.projects.some(p => p.toLowerCase().includes(lower)) ||
385
+ s.content_type.toLowerCase().includes(lower));
386
+ }
387
+ }
388
+ // Apply content type filter if set
389
+ if (state.currentContentType) {
390
+ state.filtered = state.filtered.filter(s => s.content_type === state.currentContentType);
391
+ }
392
+ // Rebuild list items if in grouped mode
393
+ if (state.groupByProject) {
394
+ state.listItems = buildListItems(state);
395
+ }
396
+ state.selectedIndex = 0;
397
+ updateStatus(ui, state, project, sourceType);
398
+ renderList(ui, state);
399
+ renderPreview(ui, state);
400
+ ui.screen.render();
401
+ }
402
+ /**
403
+ * Show help overlay
404
+ */
405
+ export function showHelp(state, ui) {
406
+ state.mode = 'help';
407
+ ui.helpPane.show();
408
+ ui.screen.render();
409
+ }
410
+ /**
411
+ * Hide help overlay
412
+ */
413
+ export function hideHelp(state, ui) {
414
+ state.mode = 'list';
415
+ ui.helpPane.hide();
416
+ ui.screen.render();
417
+ }
418
+ // ============================================================================
419
+ // Tools View
420
+ // ============================================================================
421
+ export async function openInEditor(state, ui, sourcesDir) {
422
+ if (state.filtered.length === 0)
423
+ return;
424
+ // Get selected source (handles both grouped and flat view)
425
+ const source = getSelectedSource(state);
426
+ if (!source)
427
+ return;
428
+ const editorEnv = process.env.EDITOR || 'vi';
429
+ // Parse editor command (might include args like "code -w")
430
+ const editorParts = editorEnv.split(/\s+/);
431
+ const editor = editorParts[0];
432
+ const editorArgs = editorParts.slice(1);
433
+ // Get content
434
+ let content = state.fullContent;
435
+ if (!content) {
436
+ const contentPath = path.join(sourcesDir, source.id, 'content.md');
437
+ try {
438
+ const { readFile } = await import('fs/promises');
439
+ content = await readFile(contentPath, 'utf-8');
440
+ }
441
+ catch {
442
+ content = source.summary;
443
+ }
444
+ }
445
+ // Write to temp file
446
+ const tmpPath = path.join(tmpdir(), `lore-${source.id}.md`);
447
+ writeFileSync(tmpPath, content);
448
+ // Open editor in background (detached so TUI keeps running)
449
+ const child = spawn(editor, [...editorArgs, tmpPath], {
450
+ detached: true,
451
+ stdio: 'ignore',
452
+ });
453
+ child.unref(); // Don't wait for editor to exit
454
+ // Show confirmation
455
+ ui.statusBar.setContent(` Opened in ${editor}`);
456
+ ui.screen.render();
457
+ // Clean up temp file after a delay (give editor time to read it)
458
+ setTimeout(() => {
459
+ try {
460
+ unlinkSync(tmpPath);
461
+ }
462
+ catch {
463
+ // Ignore - file might still be in use
464
+ }
465
+ }, 5000);
466
+ }
467
+ // Navigation functions
468
+ export function moveDown(state, ui) {
469
+ if (state.mode === 'fullview') {
470
+ const maxScroll = Math.max(0, state.fullContentLines.length - (ui.fullViewContent.height - 1));
471
+ state.scrollOffset = Math.min(state.scrollOffset + 1, maxScroll);
472
+ renderFullView(ui, state);
473
+ }
474
+ else if (state.mode === 'list') {
475
+ const maxIndex = state.groupByProject
476
+ ? state.listItems.length - 1
477
+ : state.filtered.length - 1;
478
+ if (state.selectedIndex < maxIndex) {
479
+ state.selectedIndex++;
480
+ renderList(ui, state);
481
+ renderPreview(ui, state);
482
+ }
483
+ }
484
+ ui.screen.render();
485
+ }
486
+ export function moveUp(state, ui) {
487
+ if (state.mode === 'fullview') {
488
+ state.scrollOffset = Math.max(0, state.scrollOffset - 1);
489
+ renderFullView(ui, state);
490
+ }
491
+ else if (state.mode === 'list') {
492
+ if (state.selectedIndex > 0) {
493
+ state.selectedIndex--;
494
+ renderList(ui, state);
495
+ renderPreview(ui, state);
496
+ }
497
+ }
498
+ ui.screen.render();
499
+ }
500
+ export function pageDown(state, ui) {
501
+ const pageSize = state.mode === 'fullview'
502
+ ? ui.fullViewContent.height - 2
503
+ : ui.listContent.height / 3;
504
+ if (state.mode === 'fullview') {
505
+ const maxScroll = Math.max(0, state.fullContentLines.length - (ui.fullViewContent.height - 1));
506
+ state.scrollOffset = Math.min(state.scrollOffset + pageSize, maxScroll);
507
+ renderFullView(ui, state);
508
+ }
509
+ else if (state.mode === 'list') {
510
+ const maxIndex = state.groupByProject
511
+ ? state.listItems.length - 1
512
+ : state.filtered.length - 1;
513
+ state.selectedIndex = Math.min(state.selectedIndex + Math.floor(pageSize), maxIndex);
514
+ renderList(ui, state);
515
+ renderPreview(ui, state);
516
+ }
517
+ ui.screen.render();
518
+ }
519
+ export function pageUp(state, ui) {
520
+ const pageSize = state.mode === 'fullview'
521
+ ? ui.fullViewContent.height - 2
522
+ : ui.listContent.height / 3;
523
+ if (state.mode === 'fullview') {
524
+ state.scrollOffset = Math.max(0, state.scrollOffset - pageSize);
525
+ renderFullView(ui, state);
526
+ }
527
+ else if (state.mode === 'list') {
528
+ state.selectedIndex = Math.max(state.selectedIndex - Math.floor(pageSize), 0);
529
+ renderList(ui, state);
530
+ renderPreview(ui, state);
531
+ }
532
+ ui.screen.render();
533
+ }
534
+ export function jumpToEnd(state, ui) {
535
+ if (state.mode === 'fullview') {
536
+ state.scrollOffset = Math.max(0, state.fullContentLines.length - (ui.fullViewContent.height - 1));
537
+ renderFullView(ui, state);
538
+ }
539
+ else if (state.mode === 'list') {
540
+ const maxIndex = state.groupByProject
541
+ ? state.listItems.length - 1
542
+ : state.filtered.length - 1;
543
+ state.selectedIndex = maxIndex;
544
+ renderList(ui, state);
545
+ renderPreview(ui, state);
546
+ }
547
+ ui.screen.render();
548
+ }
549
+ export function jumpToStart(state, ui) {
550
+ if (state.mode === 'fullview') {
551
+ state.scrollOffset = 0;
552
+ renderFullView(ui, state);
553
+ }
554
+ else if (state.mode === 'list') {
555
+ state.selectedIndex = 0;
556
+ renderList(ui, state);
557
+ renderPreview(ui, state);
558
+ }
559
+ ui.screen.render();
560
+ }
561
+ /**
562
+ * Trigger a manual sync
563
+ */
564
+ export async function triggerSync(state, ui, dbPath, dataDir, project, sourceType) {
565
+ ui.statusBar.setContent(' {yellow-fg}Syncing...{/yellow-fg}');
566
+ ui.screen.render();
567
+ try {
568
+ const { handleSync } = await import('../mcp/handlers/sync.js');
569
+ const { getAllSources } = await import('../core/vector-store.js');
570
+ const result = await handleSync(dbPath, dataDir, {
571
+ git_pull: true,
572
+ git_push: true,
573
+ }, { hookContext: { mode: 'cli' } });
574
+ const processed = result.processing?.processed || 0;
575
+ // Reload sources if anything was processed
576
+ if (processed > 0) {
577
+ state.sources = await getAllSources(dbPath, {
578
+ project,
579
+ source_type: sourceType,
580
+ limit: 100,
581
+ });
582
+ state.filtered = [...state.sources];
583
+ state.selectedIndex = 0;
584
+ renderList(ui, state);
585
+ renderPreview(ui, state);
586
+ }
587
+ updateStatus(ui, state, project, sourceType);
588
+ ui.statusBar.setContent(` {green-fg}Synced: ${processed} new file(s){/green-fg}`);
589
+ ui.screen.render();
590
+ // Restore normal status after a delay
591
+ setTimeout(() => {
592
+ updateStatus(ui, state, project, sourceType);
593
+ ui.screen.render();
594
+ }, 2000);
595
+ }
596
+ catch (error) {
597
+ ui.statusBar.setContent(` {red-fg}Sync failed: ${error}{/red-fg}`);
598
+ ui.screen.render();
599
+ }
600
+ }
601
+ // ============================================================================
602
+ // Project Picker
603
+ // ============================================================================
604
+ /**
605
+ * Load projects and show the project picker
606
+ */
607
+ export async function showProjectPicker(state, ui, dbPath) {
608
+ ui.statusBar.setContent(' Loading projects...');
609
+ ui.screen.render();
610
+ try {
611
+ const stats = await getProjectStats(dbPath);
612
+ // Build projects list with special entries
613
+ const projects = [];
614
+ // Add "All Projects" option
615
+ projects.push({
616
+ name: '__all__',
617
+ count: state.sources.length,
618
+ latestActivity: new Date().toISOString(),
619
+ });
620
+ // Add "Unassigned" option (docs with empty projects array)
621
+ const unassignedCount = state.sources.filter(s => s.projects.length === 0).length;
622
+ if (unassignedCount > 0) {
623
+ projects.push({
624
+ name: '__unassigned__',
625
+ count: unassignedCount,
626
+ latestActivity: new Date().toISOString(),
627
+ });
628
+ }
629
+ // Add actual projects
630
+ for (const stat of stats) {
631
+ projects.push({
632
+ name: stat.project,
633
+ count: stat.source_count,
634
+ latestActivity: stat.latest_activity,
635
+ });
636
+ }
637
+ state.projects = projects;
638
+ state.projectPickerIndex = 0;
639
+ // Find current project in list
640
+ if (state.currentProject) {
641
+ const idx = projects.findIndex(p => p.name === state.currentProject);
642
+ if (idx >= 0)
643
+ state.projectPickerIndex = idx;
644
+ }
645
+ state.mode = 'project-picker';
646
+ renderProjectPicker(state, ui);
647
+ ui.projectPicker.show();
648
+ ui.screen.render();
649
+ }
650
+ catch (error) {
651
+ ui.statusBar.setContent(` {red-fg}Failed to load projects: ${error}{/red-fg}`);
652
+ ui.screen.render();
653
+ }
654
+ }
655
+ /**
656
+ * Render the project picker content
657
+ */
658
+ export function renderProjectPicker(state, ui) {
659
+ const lines = [];
660
+ lines.push('{bold}{yellow-fg}Select Project{/yellow-fg}{/bold}');
661
+ lines.push('');
662
+ for (let i = 0; i < state.projects.length; i++) {
663
+ const p = state.projects[i];
664
+ const isSelected = i === state.projectPickerIndex;
665
+ const prefix = isSelected ? '{inverse} > ' : ' ';
666
+ const suffix = isSelected ? ' {/inverse}' : '';
667
+ let displayName;
668
+ let extra = '';
669
+ if (p.name === '__all__') {
670
+ displayName = '{cyan-fg}[All Projects]{/cyan-fg}';
671
+ extra = ` (${p.count})`;
672
+ }
673
+ else if (p.name === '__unassigned__') {
674
+ displayName = '{magenta-fg}[Unassigned]{/magenta-fg}';
675
+ extra = ` (${p.count})`;
676
+ }
677
+ else {
678
+ displayName = p.name;
679
+ const ago = formatRelativeTime(p.latestActivity);
680
+ extra = ` (${p.count}, ${ago})`;
681
+ }
682
+ lines.push(`${prefix}${displayName}${extra}${suffix}`);
683
+ }
684
+ lines.push('');
685
+ lines.push('{blue-fg}j/k: navigate Enter: select Esc: cancel{/blue-fg}');
686
+ ui.projectPickerContent.setContent(lines.join('\n'));
687
+ }
688
+ /**
689
+ * Format relative time for project display
690
+ */
691
+ function formatRelativeTime(isoTime) {
692
+ const date = new Date(isoTime);
693
+ const now = new Date();
694
+ const diffMs = now.getTime() - date.getTime();
695
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
696
+ if (diffDays === 0)
697
+ return 'today';
698
+ if (diffDays === 1)
699
+ return '1d ago';
700
+ if (diffDays < 7)
701
+ return `${diffDays}d ago`;
702
+ if (diffDays < 30)
703
+ return `${Math.floor(diffDays / 7)}w ago`;
704
+ if (diffDays < 365)
705
+ return `${Math.floor(diffDays / 30)}mo ago`;
706
+ return `${Math.floor(diffDays / 365)}y ago`;
707
+ }
708
+ /**
709
+ * Navigate project picker down
710
+ */
711
+ export function projectPickerDown(state, ui) {
712
+ if (state.projectPickerIndex < state.projects.length - 1) {
713
+ state.projectPickerIndex++;
714
+ renderProjectPicker(state, ui);
715
+ ui.screen.render();
716
+ }
717
+ }
718
+ /**
719
+ * Navigate project picker up
720
+ */
721
+ export function projectPickerUp(state, ui) {
722
+ if (state.projectPickerIndex > 0) {
723
+ state.projectPickerIndex--;
724
+ renderProjectPicker(state, ui);
725
+ ui.screen.render();
726
+ }
727
+ }
728
+ /**
729
+ * Select current project and filter documents
730
+ */
731
+ export async function selectProject(state, ui, dbPath, dataDir, sourceType) {
732
+ const selected = state.projects[state.projectPickerIndex];
733
+ // Hide picker
734
+ ui.projectPicker.hide();
735
+ let newProject;
736
+ if (selected.name === '__all__') {
737
+ newProject = undefined;
738
+ }
739
+ else if (selected.name === '__unassigned__') {
740
+ newProject = '__unassigned__';
741
+ }
742
+ else {
743
+ newProject = selected.name;
744
+ }
745
+ state.currentProject = newProject;
746
+ // Check if we should return to ask/research mode
747
+ if (state.pickerReturnMode === 'ask') {
748
+ state.mode = 'ask';
749
+ state.pickerReturnMode = undefined;
750
+ ui.listPane.hide();
751
+ ui.previewPane.hide();
752
+ ui.askInput.show();
753
+ ui.askPane.show();
754
+ ui.askPane.setLabel(' Ask Lore ');
755
+ renderReturnToAskOrResearch(state, ui, 'ask');
756
+ ui.askInput.focus();
757
+ ui.askInput.readInput();
758
+ ui.screen.render();
759
+ return;
760
+ }
761
+ else if (state.pickerReturnMode === 'research') {
762
+ state.mode = 'research';
763
+ state.pickerReturnMode = undefined;
764
+ ui.listPane.hide();
765
+ ui.previewPane.hide();
766
+ ui.askInput.show();
767
+ ui.askPane.show();
768
+ ui.askPane.setLabel(' Research Agent ');
769
+ renderReturnToAskOrResearch(state, ui, 'research');
770
+ ui.askInput.focus();
771
+ ui.askInput.readInput();
772
+ ui.screen.render();
773
+ return;
774
+ }
775
+ state.mode = 'list';
776
+ // Reload sources with new project filter
777
+ ui.statusBar.setContent(' Filtering...');
778
+ ui.screen.render();
779
+ try {
780
+ if (newProject === '__unassigned__') {
781
+ // Special case: filter for docs with no project
782
+ const allSources = await getAllSources(dbPath, {
783
+ source_type: sourceType,
784
+ limit: 100,
785
+ });
786
+ state.sources = allSources.filter(s => s.projects.length === 0);
787
+ }
788
+ else {
789
+ state.sources = await getAllSources(dbPath, {
790
+ project: newProject,
791
+ source_type: sourceType,
792
+ limit: 100,
793
+ });
794
+ }
795
+ state.filtered = [...state.sources];
796
+ state.selectedIndex = 0;
797
+ state.searchQuery = '';
798
+ updateStatus(ui, state, newProject, sourceType);
799
+ renderList(ui, state);
800
+ renderPreview(ui, state);
801
+ ui.screen.render();
802
+ }
803
+ catch (error) {
804
+ ui.statusBar.setContent(` {red-fg}Filter failed: ${error}{/red-fg}`);
805
+ ui.screen.render();
806
+ }
807
+ }
808
+ /**
809
+ * Cancel project picker
810
+ */
811
+ export function cancelProjectPicker(state, ui) {
812
+ ui.projectPicker.hide();
813
+ // Check if we should return to ask/research mode
814
+ if (state.pickerReturnMode === 'ask') {
815
+ state.mode = 'ask';
816
+ state.pickerReturnMode = undefined;
817
+ ui.listPane.hide();
818
+ ui.previewPane.hide();
819
+ ui.askInput.show();
820
+ ui.askPane.show();
821
+ ui.askPane.setLabel(' Ask Lore ');
822
+ renderReturnToAskOrResearch(state, ui, 'ask');
823
+ ui.askInput.focus();
824
+ ui.askInput.readInput();
825
+ }
826
+ else if (state.pickerReturnMode === 'research') {
827
+ state.mode = 'research';
828
+ state.pickerReturnMode = undefined;
829
+ ui.listPane.hide();
830
+ ui.previewPane.hide();
831
+ ui.askInput.show();
832
+ ui.askPane.show();
833
+ ui.askPane.setLabel(' Research Agent ');
834
+ renderReturnToAskOrResearch(state, ui, 'research');
835
+ ui.askInput.focus();
836
+ ui.askInput.readInput();
837
+ }
838
+ else {
839
+ state.mode = 'list';
840
+ }
841
+ ui.screen.render();
842
+ }
843
+ /**
844
+ * Clear project filter (show all)
845
+ */
846
+ export async function clearProjectFilter(state, ui, dbPath, dataDir, sourceType) {
847
+ state.currentProject = undefined;
848
+ ui.statusBar.setContent(' Loading all documents...');
849
+ ui.screen.render();
850
+ try {
851
+ state.sources = await getAllSources(dbPath, {
852
+ source_type: sourceType,
853
+ limit: 100,
854
+ });
855
+ state.filtered = [...state.sources];
856
+ state.selectedIndex = 0;
857
+ state.searchQuery = '';
858
+ updateStatus(ui, state, undefined, sourceType);
859
+ renderList(ui, state);
860
+ renderPreview(ui, state);
861
+ ui.screen.render();
862
+ }
863
+ catch (error) {
864
+ ui.statusBar.setContent(` {red-fg}Failed: ${error}{/red-fg}`);
865
+ ui.screen.render();
866
+ }
867
+ }
868
+ // ============================================================================
869
+ // Delete Document
870
+ // ============================================================================
871
+ /**
872
+ * Show delete confirmation dialog (for document or project)
873
+ */
874
+ export function showDeleteConfirm(state, ui) {
875
+ if (state.filtered.length === 0)
876
+ return;
877
+ // Check if we're on a project header (project deletion)
878
+ if (state.groupByProject && state.listItems.length > 0) {
879
+ const item = state.listItems[state.selectedIndex];
880
+ if (item?.type === 'header') {
881
+ showProjectDeleteConfirm(state, ui, item);
882
+ return;
883
+ }
884
+ }
885
+ // Get selected source (handles both grouped and flat view)
886
+ const source = getSelectedSource(state);
887
+ if (!source)
888
+ return;
889
+ const title = source.title.length > 40
890
+ ? source.title.slice(0, 37) + '...'
891
+ : source.title;
892
+ state.mode = 'delete-confirm';
893
+ const lines = [
894
+ '',
895
+ '{bold}{red-fg}Delete Document?{/red-fg}{/bold}',
896
+ '',
897
+ ` {bold}${title}{/bold}`,
898
+ '',
899
+ '{yellow-fg}This will delete from Supabase and local files.{/yellow-fg}',
900
+ '',
901
+ '{blue-fg} y: confirm delete n/Esc: cancel{/blue-fg}',
902
+ ];
903
+ ui.deleteConfirm.setContent(lines.join('\n'));
904
+ ui.deleteConfirm.show();
905
+ ui.screen.render();
906
+ }
907
+ /**
908
+ * Show delete confirmation for an entire project
909
+ */
910
+ function showProjectDeleteConfirm(state, ui, header) {
911
+ state.mode = 'delete-confirm';
912
+ const lines = [
913
+ '',
914
+ '{bold}{red-fg}Delete Entire Project?{/red-fg}{/bold}',
915
+ '',
916
+ ` {bold}{yellow-fg}${header.displayName}{/yellow-fg}{/bold}`,
917
+ '',
918
+ `{yellow-fg}This will delete ${header.documentCount} document${header.documentCount !== 1 ? 's' : ''}{/yellow-fg}`,
919
+ '{yellow-fg}from Supabase and local files.{/yellow-fg}',
920
+ '',
921
+ '{blue-fg} y: confirm delete n/Esc: cancel{/blue-fg}',
922
+ ];
923
+ ui.deleteConfirm.setContent(lines.join('\n'));
924
+ ui.deleteConfirm.show();
925
+ ui.screen.render();
926
+ }
927
+ /**
928
+ * Cancel delete operation
929
+ */
930
+ export function cancelDelete(state, ui) {
931
+ state.mode = 'list';
932
+ ui.deleteConfirm.hide();
933
+ ui.screen.render();
934
+ }
935
+ /**
936
+ * Confirm and execute delete operation (document or project)
937
+ */
938
+ export async function confirmDelete(state, ui, dbPath, dataDir, project, sourceType) {
939
+ if (state.filtered.length === 0) {
940
+ cancelDelete(state, ui);
941
+ return;
942
+ }
943
+ // Check if we're deleting a project
944
+ if (state.groupByProject && state.listItems.length > 0) {
945
+ const item = state.listItems[state.selectedIndex];
946
+ if (item?.type === 'header') {
947
+ await confirmProjectDelete(state, ui, dbPath, dataDir, item, project, sourceType);
948
+ return;
949
+ }
950
+ }
951
+ // Get selected source (handles both grouped and flat view)
952
+ const source = getSelectedSource(state);
953
+ if (!source) {
954
+ cancelDelete(state, ui);
955
+ return;
956
+ }
957
+ // Hide dialog and show progress
958
+ ui.deleteConfirm.hide();
959
+ state.mode = 'list';
960
+ ui.statusBar.setContent(` {yellow-fg}Deleting "${source.title}"...{/yellow-fg}`);
961
+ ui.screen.render();
962
+ try {
963
+ // 1. Delete from Supabase (this also handles chunks cascade)
964
+ const { sourcePath: originalPath } = await deleteSource(dbPath, source.id);
965
+ // 2. Delete local files in data directory
966
+ const { rm } = await import('fs/promises');
967
+ const loreSourcePath = path.join(dataDir, 'sources', source.id);
968
+ try {
969
+ await rm(loreSourcePath, { recursive: true });
970
+ }
971
+ catch {
972
+ // File may not exist on disk - that's ok
973
+ }
974
+ // 3. Delete original source file from sync directory (and commit to its repo)
975
+ if (originalPath) {
976
+ await deleteFileAndCommit(originalPath, `Delete: ${source.title.slice(0, 50)}`);
977
+ }
978
+ // 4. Git commit and push the lore-data changes
979
+ await gitCommitAndPush(dataDir, `Delete source: ${source.title.slice(0, 50)}`);
980
+ // 4. Refresh the source list
981
+ state.sources = await getAllSources(dbPath, {
982
+ project: state.currentProject,
983
+ source_type: sourceType,
984
+ limit: 100,
985
+ });
986
+ state.filtered = [...state.sources];
987
+ // Rebuild list items if in grouped mode
988
+ if (state.groupByProject) {
989
+ state.listItems = buildListItems(state);
990
+ // Adjust selection if needed
991
+ if (state.selectedIndex >= state.listItems.length) {
992
+ state.selectedIndex = Math.max(0, state.listItems.length - 1);
993
+ }
994
+ }
995
+ else {
996
+ // Adjust selection if needed
997
+ if (state.selectedIndex >= state.filtered.length) {
998
+ state.selectedIndex = Math.max(0, state.filtered.length - 1);
999
+ }
1000
+ }
1001
+ // Update UI
1002
+ updateStatus(ui, state, state.currentProject, sourceType);
1003
+ renderList(ui, state);
1004
+ renderPreview(ui, state);
1005
+ ui.statusBar.setContent(` {green-fg}Deleted successfully{/green-fg}`);
1006
+ ui.screen.render();
1007
+ // Restore normal status after delay
1008
+ setTimeout(() => {
1009
+ updateStatus(ui, state, state.currentProject, sourceType);
1010
+ ui.screen.render();
1011
+ }, 2000);
1012
+ }
1013
+ catch (error) {
1014
+ ui.statusBar.setContent(` {red-fg}Delete failed: ${error}{/red-fg}`);
1015
+ ui.screen.render();
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Confirm and execute project deletion (all documents in a project)
1020
+ */
1021
+ async function confirmProjectDelete(state, ui, dbPath, dataDir, header, project, sourceType) {
1022
+ // Hide dialog and show progress
1023
+ ui.deleteConfirm.hide();
1024
+ state.mode = 'list';
1025
+ ui.statusBar.setContent(` {yellow-fg}Deleting ${header.documentCount} documents from "${header.displayName}"...{/yellow-fg}`);
1026
+ ui.screen.render();
1027
+ try {
1028
+ // Get all documents in this project
1029
+ const docsToDelete = state.filtered.filter(s => {
1030
+ if (header.projectName === '__unassigned__') {
1031
+ return s.projects.length === 0;
1032
+ }
1033
+ return s.projects.includes(header.projectName);
1034
+ });
1035
+ let deleted = 0;
1036
+ const errors = [];
1037
+ const { rm } = await import('fs/promises');
1038
+ for (const source of docsToDelete) {
1039
+ try {
1040
+ // Delete from Supabase
1041
+ const { sourcePath: originalPath } = await deleteSource(dbPath, source.id);
1042
+ // Delete local files
1043
+ const loreSourcePath = path.join(dataDir, 'sources', source.id);
1044
+ try {
1045
+ await rm(loreSourcePath, { recursive: true });
1046
+ }
1047
+ catch {
1048
+ // File may not exist on disk
1049
+ }
1050
+ // Delete original source file from sync directory (and commit to its repo)
1051
+ if (originalPath) {
1052
+ await deleteFileAndCommit(originalPath, `Delete: ${source.title.slice(0, 50)}`);
1053
+ }
1054
+ deleted++;
1055
+ ui.statusBar.setContent(` {yellow-fg}Deleting... ${deleted}/${docsToDelete.length}{/yellow-fg}`);
1056
+ ui.screen.render();
1057
+ }
1058
+ catch (err) {
1059
+ errors.push(`${source.title}: ${err}`);
1060
+ }
1061
+ }
1062
+ // Git commit and push the deletions
1063
+ await gitCommitAndPush(dataDir, `Delete project: ${header.displayName} (${deleted} documents)`);
1064
+ // Remove from expanded set
1065
+ state.expandedProjects.delete(header.projectName);
1066
+ // Clear project filter - after deleting a project, show all remaining
1067
+ state.currentProject = undefined;
1068
+ // Refresh the source list (no project filter - show all remaining)
1069
+ state.sources = await getAllSources(dbPath, {
1070
+ source_type: sourceType,
1071
+ limit: 100,
1072
+ });
1073
+ state.filtered = [...state.sources];
1074
+ // Rebuild list items
1075
+ if (state.groupByProject) {
1076
+ state.listItems = buildListItems(state);
1077
+ if (state.selectedIndex >= state.listItems.length) {
1078
+ state.selectedIndex = Math.max(0, state.listItems.length - 1);
1079
+ }
1080
+ }
1081
+ else {
1082
+ if (state.selectedIndex >= state.filtered.length) {
1083
+ state.selectedIndex = Math.max(0, state.filtered.length - 1);
1084
+ }
1085
+ }
1086
+ // Update UI
1087
+ updateStatus(ui, state, state.currentProject, sourceType);
1088
+ renderList(ui, state);
1089
+ renderPreview(ui, state);
1090
+ if (errors.length > 0) {
1091
+ ui.statusBar.setContent(` {yellow-fg}Deleted ${deleted} documents, ${errors.length} failed{/yellow-fg}`);
1092
+ }
1093
+ else {
1094
+ ui.statusBar.setContent(` {green-fg}Deleted ${deleted} documents{/green-fg}`);
1095
+ }
1096
+ ui.screen.render();
1097
+ // Restore normal status after delay
1098
+ setTimeout(() => {
1099
+ updateStatus(ui, state, state.currentProject, sourceType);
1100
+ ui.screen.render();
1101
+ }, 2000);
1102
+ }
1103
+ catch (error) {
1104
+ ui.statusBar.setContent(` {red-fg}Delete failed: ${error}{/red-fg}`);
1105
+ ui.screen.render();
1106
+ }
1107
+ }
1108
+ // ============================================================================
1109
+ // Clipboard Copy
1110
+ // ============================================================================
1111
+ /**
1112
+ * Copy text to system clipboard
1113
+ */
1114
+ function copyToClipboard(text) {
1115
+ return new Promise((resolve, reject) => {
1116
+ const platform = process.platform;
1117
+ let cmd;
1118
+ let args;
1119
+ if (platform === 'darwin') {
1120
+ cmd = 'pbcopy';
1121
+ args = [];
1122
+ }
1123
+ else if (platform === 'linux') {
1124
+ // Try xclip first, fall back to xsel
1125
+ cmd = 'xclip';
1126
+ args = ['-selection', 'clipboard'];
1127
+ }
1128
+ else if (platform === 'win32') {
1129
+ cmd = 'clip';
1130
+ args = [];
1131
+ }
1132
+ else {
1133
+ reject(new Error(`Unsupported platform: ${platform}`));
1134
+ return;
1135
+ }
1136
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
1137
+ proc.stdin?.write(text);
1138
+ proc.stdin?.end();
1139
+ proc.on('close', (code) => {
1140
+ if (code === 0) {
1141
+ resolve();
1142
+ }
1143
+ else {
1144
+ reject(new Error(`Clipboard command failed with code ${code}`));
1145
+ }
1146
+ });
1147
+ proc.on('error', (err) => {
1148
+ reject(err);
1149
+ });
1150
+ });
1151
+ }
1152
+ /**
1153
+ * Copy current view content to clipboard
1154
+ */
1155
+ export async function copyCurrentContent(state, ui) {
1156
+ let content = null;
1157
+ let description = '';
1158
+ if (state.mode === 'fullview') {
1159
+ content = state.fullContent;
1160
+ description = 'Document';
1161
+ }
1162
+ else if (state.mode === 'ask' && state.askResponse) {
1163
+ content = state.askResponse;
1164
+ description = 'Response';
1165
+ }
1166
+ else if (state.mode === 'research' && state.researchResponse) {
1167
+ content = state.researchResponse;
1168
+ description = 'Research result';
1169
+ }
1170
+ if (!content) {
1171
+ ui.statusBar.setContent(' {yellow-fg}Nothing to copy{/yellow-fg}');
1172
+ ui.screen.render();
1173
+ setTimeout(() => {
1174
+ updateStatus(ui, state, state.currentProject);
1175
+ ui.screen.render();
1176
+ }, 1500);
1177
+ return;
1178
+ }
1179
+ try {
1180
+ await copyToClipboard(content);
1181
+ ui.statusBar.setContent(` {green-fg}${description} copied to clipboard{/green-fg}`);
1182
+ ui.screen.render();
1183
+ setTimeout(() => {
1184
+ updateStatus(ui, state, state.currentProject);
1185
+ ui.screen.render();
1186
+ }, 1500);
1187
+ }
1188
+ catch (error) {
1189
+ ui.statusBar.setContent(` {red-fg}Copy failed: ${error}{/red-fg}`);
1190
+ ui.screen.render();
1191
+ }
1192
+ }
1193
+ // ============================================================================
1194
+ // Move Document to Project
1195
+ // ============================================================================
1196
+ /**
1197
+ * Show move picker to relocate a document to a different project
1198
+ */
1199
+ export async function showMovePicker(state, ui, dbPath) {
1200
+ // Get selected source
1201
+ const source = getSelectedSource(state);
1202
+ if (!source) {
1203
+ ui.statusBar.setContent(' {yellow-fg}No document selected{/yellow-fg}');
1204
+ ui.screen.render();
1205
+ return;
1206
+ }
1207
+ state.moveTargetSource = source;
1208
+ ui.statusBar.setContent(' Loading projects...');
1209
+ ui.screen.render();
1210
+ try {
1211
+ const stats = await getProjectStats(dbPath);
1212
+ // Build projects list (no "All Projects" option - doesn't make sense for move)
1213
+ const projects = [];
1214
+ // Add "New Project..." option at top
1215
+ projects.push({
1216
+ name: '__new__',
1217
+ count: 0,
1218
+ latestActivity: new Date().toISOString(),
1219
+ });
1220
+ // Add actual projects
1221
+ for (const stat of stats) {
1222
+ projects.push({
1223
+ name: stat.project,
1224
+ count: stat.source_count,
1225
+ latestActivity: stat.latest_activity,
1226
+ });
1227
+ }
1228
+ state.movePickerProjects = projects;
1229
+ state.movePickerIndex = 0;
1230
+ // Find current project in list (skip if doc has no project)
1231
+ const currentProj = source.projects[0];
1232
+ if (currentProj) {
1233
+ const idx = projects.findIndex(p => p.name === currentProj);
1234
+ if (idx >= 0)
1235
+ state.movePickerIndex = idx;
1236
+ }
1237
+ state.mode = 'move-picker';
1238
+ renderMovePicker(state, ui);
1239
+ ui.projectPicker.show();
1240
+ ui.screen.render();
1241
+ }
1242
+ catch (error) {
1243
+ ui.statusBar.setContent(` {red-fg}Failed to load projects: ${error}{/red-fg}`);
1244
+ ui.screen.render();
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Render the move picker UI
1249
+ */
1250
+ export function renderMovePicker(state, ui) {
1251
+ const source = state.moveTargetSource;
1252
+ const currentProject = source?.projects[0] || '(none)';
1253
+ const lines = [];
1254
+ lines.push('{bold}{yellow-fg}Move Document to Project{/yellow-fg}{/bold}');
1255
+ lines.push(`{gray-fg}Current: ${currentProject}{/gray-fg}`);
1256
+ lines.push('');
1257
+ for (let i = 0; i < state.movePickerProjects.length; i++) {
1258
+ const p = state.movePickerProjects[i];
1259
+ const isSelected = i === state.movePickerIndex;
1260
+ const isCurrent = p.name === source?.projects[0];
1261
+ const prefix = isSelected ? '{inverse} > ' : ' ';
1262
+ const suffix = isSelected ? ' {/inverse}' : '';
1263
+ let displayName;
1264
+ let extra = '';
1265
+ if (p.name === '__new__') {
1266
+ displayName = '{cyan-fg}[New Project...]{/cyan-fg}';
1267
+ }
1268
+ else {
1269
+ displayName = p.name;
1270
+ const ago = formatRelativeTime(p.latestActivity);
1271
+ extra = ` (${p.count}, ${ago})`;
1272
+ if (isCurrent) {
1273
+ extra += ' {magenta-fg}(current){/magenta-fg}';
1274
+ }
1275
+ }
1276
+ lines.push(`${prefix}${displayName}${extra}${suffix}`);
1277
+ }
1278
+ lines.push('');
1279
+ lines.push('{blue-fg}j/k: navigate Enter: move Esc: cancel{/blue-fg}');
1280
+ ui.projectPickerContent.setContent(lines.join('\n'));
1281
+ }
1282
+ /**
1283
+ * Navigate down in move picker
1284
+ */
1285
+ export function movePickerDown(state, ui) {
1286
+ if (state.movePickerIndex < state.movePickerProjects.length - 1) {
1287
+ state.movePickerIndex++;
1288
+ renderMovePicker(state, ui);
1289
+ ui.screen.render();
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Navigate up in move picker
1294
+ */
1295
+ export function movePickerUp(state, ui) {
1296
+ if (state.movePickerIndex > 0) {
1297
+ state.movePickerIndex--;
1298
+ renderMovePicker(state, ui);
1299
+ ui.screen.render();
1300
+ }
1301
+ }
1302
+ /**
1303
+ * Confirm move to selected project
1304
+ */
1305
+ export async function confirmMove(state, ui, dbPath, dataDir, sourceType) {
1306
+ const source = state.moveTargetSource;
1307
+ if (!source) {
1308
+ cancelMovePicker(state, ui);
1309
+ return;
1310
+ }
1311
+ const selected = state.movePickerProjects[state.movePickerIndex];
1312
+ // Handle "New Project..." option
1313
+ if (selected.name === '__new__') {
1314
+ // Show input for new project name
1315
+ ui.projectPicker.hide();
1316
+ state.mode = 'list';
1317
+ // For now, show a message that they need to type a project name
1318
+ // In the future, we could add a text input modal
1319
+ ui.statusBar.setContent(' {yellow-fg}New project creation coming soon. Use edit (i) to set project name.{/yellow-fg}');
1320
+ ui.screen.render();
1321
+ setTimeout(() => {
1322
+ updateStatus(ui, state, state.currentProject, sourceType);
1323
+ ui.screen.render();
1324
+ }, 2000);
1325
+ return;
1326
+ }
1327
+ // Don't move if same project
1328
+ if (source.projects.includes(selected.name)) {
1329
+ ui.projectPicker.hide();
1330
+ state.mode = 'list';
1331
+ ui.statusBar.setContent(' {yellow-fg}Document already in this project{/yellow-fg}');
1332
+ ui.screen.render();
1333
+ setTimeout(() => {
1334
+ updateStatus(ui, state, state.currentProject, sourceType);
1335
+ ui.screen.render();
1336
+ }, 1500);
1337
+ return;
1338
+ }
1339
+ ui.projectPicker.hide();
1340
+ ui.statusBar.setContent(` Moving to "${selected.name}"...`);
1341
+ ui.screen.render();
1342
+ try {
1343
+ // Update the source's projects array (replace, not add)
1344
+ const success = await updateSourceProjects(dbPath, source.id, [selected.name]);
1345
+ if (!success) {
1346
+ throw new Error('Failed to update source');
1347
+ }
1348
+ // Update the local source object
1349
+ source.projects = [selected.name];
1350
+ // Rebuild list if in grouped mode
1351
+ if (state.groupByProject) {
1352
+ state.listItems = buildListItems(state);
1353
+ }
1354
+ state.mode = 'list';
1355
+ renderList(ui, state);
1356
+ renderPreview(ui, state);
1357
+ ui.statusBar.setContent(` {green-fg}Moved to "${selected.name}"{/green-fg}`);
1358
+ ui.screen.render();
1359
+ setTimeout(() => {
1360
+ updateStatus(ui, state, state.currentProject, sourceType);
1361
+ ui.screen.render();
1362
+ }, 1500);
1363
+ }
1364
+ catch (error) {
1365
+ state.mode = 'list';
1366
+ ui.statusBar.setContent(` {red-fg}Move failed: ${error}{/red-fg}`);
1367
+ ui.screen.render();
1368
+ }
1369
+ }
1370
+ /**
1371
+ * Cancel move picker
1372
+ */
1373
+ export function cancelMovePicker(state, ui) {
1374
+ ui.projectPicker.hide();
1375
+ state.mode = 'list';
1376
+ state.moveTargetSource = undefined;
1377
+ ui.screen.render();
1378
+ }
1379
+ // ============================================================================
1380
+ // Edit Document Info
1381
+ // ============================================================================
1382
+ /**
1383
+ * Enter edit info mode for the selected document
1384
+ */
1385
+ export function enterEditInfo(state, ui) {
1386
+ const source = getSelectedSource(state);
1387
+ if (!source) {
1388
+ ui.statusBar.setContent(' {yellow-fg}No document selected{/yellow-fg}');
1389
+ ui.screen.render();
1390
+ return;
1391
+ }
1392
+ state.editSource = source;
1393
+ state.editTitle = source.title;
1394
+ state.editProjects = [...source.projects];
1395
+ state.editFieldIndex = 0;
1396
+ state.mode = 'edit-info';
1397
+ // Hide list/preview, show edit pane
1398
+ ui.listPane.hide();
1399
+ ui.previewPane.hide();
1400
+ // Show the input with current title - update label on input box
1401
+ ui.askInput.setLabel(' Edit Title ');
1402
+ ui.askInput.setValue(source.title);
1403
+ ui.askInput.show();
1404
+ // Show info pane with instructions
1405
+ ui.askPane.setLabel(' Document Info ');
1406
+ ui.askPane.setContent('{cyan-fg}Edit the title above and press Enter to save{/cyan-fg}\n\n{gray-fg}Press Esc to cancel{/gray-fg}');
1407
+ ui.askPane.show();
1408
+ ui.footer.setContent(' Enter: Save │ Esc: Cancel');
1409
+ ui.askInput.focus();
1410
+ ui.askInput.readInput();
1411
+ ui.screen.render();
1412
+ }
1413
+ /**
1414
+ * Save edit info changes
1415
+ */
1416
+ export async function saveEditInfo(state, ui, dbPath, sourceType) {
1417
+ const source = state.editSource;
1418
+ if (!source) {
1419
+ exitEditInfo(state, ui);
1420
+ return;
1421
+ }
1422
+ ui.askPane.setContent('{yellow-fg}Saving...{/yellow-fg}');
1423
+ ui.screen.render();
1424
+ try {
1425
+ let updated = false;
1426
+ // Update title if changed
1427
+ if (state.editTitle !== source.title && state.editTitle.trim()) {
1428
+ const success = await updateSourceTitle(dbPath, source.id, state.editTitle.trim());
1429
+ if (success) {
1430
+ source.title = state.editTitle.trim();
1431
+ updated = true;
1432
+ }
1433
+ }
1434
+ // Update projects if changed
1435
+ const newProjects = state.editProjects.filter(p => p.trim());
1436
+ const projectsChanged = JSON.stringify(newProjects) !== JSON.stringify(source.projects);
1437
+ if (projectsChanged) {
1438
+ const success = await updateSourceProjects(dbPath, source.id, newProjects);
1439
+ if (success) {
1440
+ source.projects = newProjects;
1441
+ updated = true;
1442
+ }
1443
+ }
1444
+ exitEditInfo(state, ui);
1445
+ // Rebuild list if in grouped mode
1446
+ if (state.groupByProject) {
1447
+ state.listItems = buildListItems(state);
1448
+ }
1449
+ renderList(ui, state);
1450
+ renderPreview(ui, state);
1451
+ if (updated) {
1452
+ ui.statusBar.setContent(' {green-fg}Document updated{/green-fg}');
1453
+ }
1454
+ else {
1455
+ ui.statusBar.setContent(' {gray-fg}No changes{/gray-fg}');
1456
+ }
1457
+ ui.screen.render();
1458
+ setTimeout(() => {
1459
+ updateStatus(ui, state, state.currentProject, sourceType);
1460
+ ui.screen.render();
1461
+ }, 1500);
1462
+ }
1463
+ catch (error) {
1464
+ exitEditInfo(state, ui);
1465
+ ui.statusBar.setContent(` {red-fg}Save failed: ${error}{/red-fg}`);
1466
+ ui.screen.render();
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Exit edit info mode without saving
1471
+ */
1472
+ export function exitEditInfo(state, ui) {
1473
+ state.mode = 'list';
1474
+ state.editSource = undefined;
1475
+ state.editTitle = '';
1476
+ state.editProjects = [];
1477
+ state.editFieldIndex = 0;
1478
+ // Hide and reset ask components
1479
+ ui.askInput.hide();
1480
+ ui.askInput.setValue('');
1481
+ ui.askInput.setLabel(' Ask Lore ');
1482
+ ui.askPane.hide();
1483
+ ui.askPane.setLabel(' Response ');
1484
+ ui.listPane.show();
1485
+ ui.previewPane.show();
1486
+ ui.footer.setContent(' j/k Nav │ / Search │ a Ask │ R Research │ p Proj │ c Type │ m Move │ i Edit │ Esc Quit │ ? Help');
1487
+ ui.listContent.focus();
1488
+ ui.screen.render();
1489
+ }
1490
+ // ============================================================================
1491
+ // Edit Content Type (Type Picker)
1492
+ // ============================================================================
1493
+ // Valid content types
1494
+ const CONTENT_TYPES = [
1495
+ 'interview',
1496
+ 'meeting',
1497
+ 'conversation',
1498
+ 'document',
1499
+ 'note',
1500
+ 'analysis',
1501
+ ];
1502
+ /**
1503
+ * Show type picker to change document content type
1504
+ */
1505
+ export function showTypePicker(state, ui) {
1506
+ const source = getSelectedSource(state);
1507
+ if (!source) {
1508
+ ui.statusBar.setContent(' {yellow-fg}No document selected{/yellow-fg}');
1509
+ ui.screen.render();
1510
+ return;
1511
+ }
1512
+ state.typePickerSource = source;
1513
+ state.typePickerIndex = 0;
1514
+ // Find current type in list
1515
+ const currentIdx = CONTENT_TYPES.indexOf(source.content_type);
1516
+ if (currentIdx >= 0) {
1517
+ state.typePickerIndex = currentIdx;
1518
+ }
1519
+ state.mode = 'type-picker';
1520
+ renderTypePicker(state, ui);
1521
+ ui.projectPicker.show();
1522
+ ui.screen.render();
1523
+ }
1524
+ /**
1525
+ * Render the type picker UI
1526
+ */
1527
+ export function renderTypePicker(state, ui) {
1528
+ const source = state.typePickerSource;
1529
+ const currentType = source?.content_type || '(unknown)';
1530
+ const lines = [];
1531
+ lines.push('{bold}{yellow-fg}Change Content Type{/yellow-fg}{/bold}');
1532
+ lines.push(`{gray-fg}Current: ${currentType}{/gray-fg}`);
1533
+ lines.push('');
1534
+ for (let i = 0; i < CONTENT_TYPES.length; i++) {
1535
+ const type = CONTENT_TYPES[i];
1536
+ const isSelected = i === state.typePickerIndex;
1537
+ const isCurrent = type === source?.content_type;
1538
+ const prefix = isSelected ? '{inverse} > ' : ' ';
1539
+ const suffix = isSelected ? ' {/inverse}' : '';
1540
+ let displayName = type;
1541
+ let extra = '';
1542
+ if (isCurrent) {
1543
+ extra = ' {magenta-fg}(current){/magenta-fg}';
1544
+ }
1545
+ lines.push(`${prefix}${displayName}${extra}${suffix}`);
1546
+ }
1547
+ lines.push('');
1548
+ lines.push('{blue-fg}j/k: navigate Enter: select Esc: cancel{/blue-fg}');
1549
+ ui.projectPickerContent.setContent(lines.join('\n'));
1550
+ }
1551
+ /**
1552
+ * Navigate down in type picker
1553
+ */
1554
+ export function typePickerDown(state, ui) {
1555
+ if (state.typePickerIndex < CONTENT_TYPES.length - 1) {
1556
+ state.typePickerIndex++;
1557
+ renderTypePicker(state, ui);
1558
+ ui.screen.render();
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Navigate up in type picker
1563
+ */
1564
+ export function typePickerUp(state, ui) {
1565
+ if (state.typePickerIndex > 0) {
1566
+ state.typePickerIndex--;
1567
+ renderTypePicker(state, ui);
1568
+ ui.screen.render();
1569
+ }
1570
+ }
1571
+ /**
1572
+ * Confirm type selection
1573
+ */
1574
+ export async function confirmTypeChange(state, ui, dbPath, sourceType) {
1575
+ const source = state.typePickerSource;
1576
+ if (!source) {
1577
+ cancelTypePicker(state, ui);
1578
+ return;
1579
+ }
1580
+ const selectedType = CONTENT_TYPES[state.typePickerIndex];
1581
+ // Don't update if same type
1582
+ if (selectedType === source.content_type) {
1583
+ ui.projectPicker.hide();
1584
+ state.mode = 'list';
1585
+ ui.statusBar.setContent(' {yellow-fg}No change{/yellow-fg}');
1586
+ ui.screen.render();
1587
+ setTimeout(() => {
1588
+ updateStatus(ui, state, state.currentProject, sourceType);
1589
+ ui.screen.render();
1590
+ }, 1500);
1591
+ return;
1592
+ }
1593
+ ui.projectPicker.hide();
1594
+ ui.statusBar.setContent(` Updating type to "${selectedType}"...`);
1595
+ ui.screen.render();
1596
+ try {
1597
+ const success = await updateSourceContentType(dbPath, source.id, selectedType);
1598
+ if (!success) {
1599
+ throw new Error('Failed to update content type');
1600
+ }
1601
+ // Update the local source object
1602
+ source.content_type = selectedType;
1603
+ state.mode = 'list';
1604
+ renderList(ui, state);
1605
+ renderPreview(ui, state);
1606
+ ui.statusBar.setContent(` {green-fg}Type changed to "${selectedType}"{/green-fg}`);
1607
+ ui.screen.render();
1608
+ setTimeout(() => {
1609
+ updateStatus(ui, state, state.currentProject, sourceType);
1610
+ ui.screen.render();
1611
+ }, 1500);
1612
+ }
1613
+ catch (error) {
1614
+ state.mode = 'list';
1615
+ ui.statusBar.setContent(` {red-fg}Update failed: ${error}{/red-fg}`);
1616
+ ui.screen.render();
1617
+ }
1618
+ }
1619
+ /**
1620
+ * Cancel type picker
1621
+ */
1622
+ export function cancelTypePicker(state, ui) {
1623
+ ui.projectPicker.hide();
1624
+ state.mode = 'list';
1625
+ state.typePickerSource = undefined;
1626
+ ui.screen.render();
1627
+ }
1628
+ // ============================================================================
1629
+ // Content Type Filter (Filter list by content type)
1630
+ // ============================================================================
1631
+ // Content types for filtering (includes "All" option)
1632
+ const FILTER_CONTENT_TYPES = [
1633
+ '__all__',
1634
+ 'interview',
1635
+ 'meeting',
1636
+ 'conversation',
1637
+ 'document',
1638
+ 'note',
1639
+ 'analysis',
1640
+ ];
1641
+ /**
1642
+ * Show content type filter picker
1643
+ */
1644
+ export function showContentTypeFilter(state, ui) {
1645
+ state.contentTypeFilterIndex = 0;
1646
+ // Find current filter in list
1647
+ if (state.currentContentType) {
1648
+ const idx = FILTER_CONTENT_TYPES.indexOf(state.currentContentType);
1649
+ if (idx >= 0) {
1650
+ state.contentTypeFilterIndex = idx;
1651
+ }
1652
+ }
1653
+ state.mode = 'content-type-filter';
1654
+ renderContentTypeFilter(state, ui);
1655
+ ui.projectPicker.show();
1656
+ ui.screen.render();
1657
+ }
1658
+ /**
1659
+ * Render the content type filter UI
1660
+ */
1661
+ export function renderContentTypeFilter(state, ui) {
1662
+ const currentFilter = state.currentContentType || 'All';
1663
+ const lines = [];
1664
+ lines.push('{bold}{yellow-fg}Filter by Content Type{/yellow-fg}{/bold}');
1665
+ lines.push(`{gray-fg}Current: ${currentFilter}{/gray-fg}`);
1666
+ lines.push('');
1667
+ for (let i = 0; i < FILTER_CONTENT_TYPES.length; i++) {
1668
+ const type = FILTER_CONTENT_TYPES[i];
1669
+ const isSelected = i === state.contentTypeFilterIndex;
1670
+ const isCurrent = type === state.currentContentType || (type === '__all__' && !state.currentContentType);
1671
+ const prefix = isSelected ? '{inverse} > ' : ' ';
1672
+ const suffix = isSelected ? ' {/inverse}' : '';
1673
+ let displayName = type === '__all__' ? '{cyan-fg}[All Types]{/cyan-fg}' : type;
1674
+ let extra = '';
1675
+ if (isCurrent) {
1676
+ extra = ' {magenta-fg}(current){/magenta-fg}';
1677
+ }
1678
+ lines.push(`${prefix}${displayName}${extra}${suffix}`);
1679
+ }
1680
+ lines.push('');
1681
+ lines.push('{blue-fg}j/k: navigate Enter: select Esc: cancel{/blue-fg}');
1682
+ ui.projectPickerContent.setContent(lines.join('\n'));
1683
+ }
1684
+ /**
1685
+ * Navigate down in content type filter
1686
+ */
1687
+ export function contentTypeFilterDown(state, ui) {
1688
+ if (state.contentTypeFilterIndex < FILTER_CONTENT_TYPES.length - 1) {
1689
+ state.contentTypeFilterIndex++;
1690
+ renderContentTypeFilter(state, ui);
1691
+ ui.screen.render();
1692
+ }
1693
+ }
1694
+ /**
1695
+ * Navigate up in content type filter
1696
+ */
1697
+ export function contentTypeFilterUp(state, ui) {
1698
+ if (state.contentTypeFilterIndex > 0) {
1699
+ state.contentTypeFilterIndex--;
1700
+ renderContentTypeFilter(state, ui);
1701
+ ui.screen.render();
1702
+ }
1703
+ }
1704
+ /**
1705
+ * Apply content type filter
1706
+ */
1707
+ export async function applyContentTypeFilter(state, ui, dbPath, dataDir, sourceType) {
1708
+ const selectedType = FILTER_CONTENT_TYPES[state.contentTypeFilterIndex];
1709
+ ui.projectPicker.hide();
1710
+ const newFilter = selectedType === '__all__' ? undefined : selectedType;
1711
+ state.currentContentType = newFilter;
1712
+ // Check if we should return to ask/research mode
1713
+ if (state.pickerReturnMode === 'ask') {
1714
+ state.mode = 'ask';
1715
+ state.pickerReturnMode = undefined;
1716
+ ui.listPane.hide();
1717
+ ui.previewPane.hide();
1718
+ ui.askInput.show();
1719
+ ui.askPane.show();
1720
+ ui.askPane.setLabel(' Ask Lore ');
1721
+ renderReturnToAskOrResearch(state, ui, 'ask');
1722
+ ui.askInput.focus();
1723
+ ui.askInput.readInput();
1724
+ ui.screen.render();
1725
+ return;
1726
+ }
1727
+ else if (state.pickerReturnMode === 'research') {
1728
+ state.mode = 'research';
1729
+ state.pickerReturnMode = undefined;
1730
+ ui.listPane.hide();
1731
+ ui.previewPane.hide();
1732
+ ui.askInput.show();
1733
+ ui.askPane.show();
1734
+ ui.askPane.setLabel(' Research Agent ');
1735
+ renderReturnToAskOrResearch(state, ui, 'research');
1736
+ ui.askInput.focus();
1737
+ ui.askInput.readInput();
1738
+ ui.screen.render();
1739
+ return;
1740
+ }
1741
+ state.mode = 'list';
1742
+ ui.statusBar.setContent(' Filtering...');
1743
+ ui.screen.render();
1744
+ try {
1745
+ // Reload sources with content type filter
1746
+ // Note: getAllSources doesn't support content_type filter directly,
1747
+ // so we filter client-side for now
1748
+ state.sources = await getAllSources(dbPath, {
1749
+ project: state.currentProject,
1750
+ source_type: sourceType,
1751
+ limit: 100,
1752
+ });
1753
+ // Apply content type filter client-side
1754
+ if (newFilter) {
1755
+ state.filtered = state.sources.filter(s => s.content_type === newFilter);
1756
+ }
1757
+ else {
1758
+ state.filtered = [...state.sources];
1759
+ }
1760
+ state.selectedIndex = 0;
1761
+ // Rebuild list items if in grouped mode
1762
+ if (state.groupByProject) {
1763
+ state.listItems = buildListItems(state);
1764
+ }
1765
+ updateStatus(ui, state, state.currentProject, sourceType);
1766
+ renderList(ui, state);
1767
+ renderPreview(ui, state);
1768
+ ui.screen.render();
1769
+ }
1770
+ catch (error) {
1771
+ ui.statusBar.setContent(` {red-fg}Filter failed: ${error}{/red-fg}`);
1772
+ ui.screen.render();
1773
+ }
1774
+ }
1775
+ /**
1776
+ * Cancel content type filter picker
1777
+ */
1778
+ export function cancelContentTypeFilter(state, ui) {
1779
+ ui.projectPicker.hide();
1780
+ // Check if we should return to ask/research mode
1781
+ if (state.pickerReturnMode === 'ask') {
1782
+ state.mode = 'ask';
1783
+ state.pickerReturnMode = undefined;
1784
+ ui.listPane.hide();
1785
+ ui.previewPane.hide();
1786
+ ui.askInput.show();
1787
+ ui.askPane.show();
1788
+ ui.askPane.setLabel(' Ask Lore ');
1789
+ renderReturnToAskOrResearch(state, ui, 'ask');
1790
+ ui.askInput.focus();
1791
+ ui.askInput.readInput();
1792
+ }
1793
+ else if (state.pickerReturnMode === 'research') {
1794
+ state.mode = 'research';
1795
+ state.pickerReturnMode = undefined;
1796
+ ui.listPane.hide();
1797
+ ui.previewPane.hide();
1798
+ ui.askInput.show();
1799
+ ui.askPane.show();
1800
+ ui.askPane.setLabel(' Research Agent ');
1801
+ renderReturnToAskOrResearch(state, ui, 'research');
1802
+ ui.askInput.focus();
1803
+ ui.askInput.readInput();
1804
+ }
1805
+ else {
1806
+ state.mode = 'list';
1807
+ }
1808
+ ui.screen.render();
1809
+ }
1810
+ /**
1811
+ * Clear content type filter
1812
+ */
1813
+ export async function clearContentTypeFilter(state, ui, dbPath, dataDir, sourceType) {
1814
+ state.currentContentType = undefined;
1815
+ ui.statusBar.setContent(' Clearing filter...');
1816
+ ui.screen.render();
1817
+ try {
1818
+ state.sources = await getAllSources(dbPath, {
1819
+ project: state.currentProject,
1820
+ source_type: sourceType,
1821
+ limit: 100,
1822
+ });
1823
+ state.filtered = [...state.sources];
1824
+ state.selectedIndex = 0;
1825
+ if (state.groupByProject) {
1826
+ state.listItems = buildListItems(state);
1827
+ }
1828
+ updateStatus(ui, state, state.currentProject, sourceType);
1829
+ renderList(ui, state);
1830
+ renderPreview(ui, state);
1831
+ ui.screen.render();
1832
+ }
1833
+ catch (error) {
1834
+ ui.statusBar.setContent(` {red-fg}Clear failed: ${error}{/red-fg}`);
1835
+ ui.screen.render();
1836
+ }
1837
+ }
1838
+ // ============================================================================
1839
+ // Project Folders (Collapsible Groups)
1840
+ // ============================================================================
1841
+ /**
1842
+ * Toggle expand/collapse for the currently selected project header
1843
+ */
1844
+ export function toggleProjectExpand(state, ui) {
1845
+ if (!state.groupByProject || state.listItems.length === 0) {
1846
+ return false;
1847
+ }
1848
+ const item = state.listItems[state.selectedIndex];
1849
+ if (!item || item.type !== 'header') {
1850
+ return false;
1851
+ }
1852
+ // Toggle expansion
1853
+ if (state.expandedProjects.has(item.projectName)) {
1854
+ state.expandedProjects.delete(item.projectName);
1855
+ }
1856
+ else {
1857
+ state.expandedProjects.add(item.projectName);
1858
+ }
1859
+ // Rebuild list items and re-render
1860
+ state.listItems = buildListItems(state);
1861
+ renderList(ui, state);
1862
+ renderPreview(ui, state);
1863
+ ui.screen.render();
1864
+ return true;
1865
+ }
1866
+ /**
1867
+ * Expand the currently selected project (or the project containing the selected doc)
1868
+ */
1869
+ export function expandCurrentProject(state, ui) {
1870
+ if (!state.groupByProject || state.listItems.length === 0)
1871
+ return;
1872
+ const item = state.listItems[state.selectedIndex];
1873
+ if (!item)
1874
+ return;
1875
+ const projectName = item.type === 'header' ? item.projectName : item.projectName;
1876
+ if (!state.expandedProjects.has(projectName)) {
1877
+ state.expandedProjects.add(projectName);
1878
+ state.listItems = buildListItems(state);
1879
+ renderList(ui, state);
1880
+ renderPreview(ui, state);
1881
+ ui.screen.render();
1882
+ }
1883
+ }
1884
+ /**
1885
+ * Collapse the currently selected project (or the project containing the selected doc)
1886
+ */
1887
+ export function collapseCurrentProject(state, ui) {
1888
+ if (!state.groupByProject || state.listItems.length === 0)
1889
+ return;
1890
+ const item = state.listItems[state.selectedIndex];
1891
+ if (!item)
1892
+ return;
1893
+ const projectName = item.type === 'header' ? item.projectName : item.projectName;
1894
+ if (state.expandedProjects.has(projectName)) {
1895
+ state.expandedProjects.delete(projectName);
1896
+ // Find the header for this project and move selection to it
1897
+ state.listItems = buildListItems(state);
1898
+ const headerIdx = state.listItems.findIndex(i => i.type === 'header' && i.projectName === projectName);
1899
+ if (headerIdx >= 0) {
1900
+ state.selectedIndex = headerIdx;
1901
+ }
1902
+ renderList(ui, state);
1903
+ renderPreview(ui, state);
1904
+ ui.screen.render();
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Toggle grouped view mode
1909
+ */
1910
+ export function toggleGroupedView(state, ui) {
1911
+ state.groupByProject = !state.groupByProject;
1912
+ if (state.groupByProject) {
1913
+ // Switching to grouped view - rebuild list items
1914
+ state.listItems = buildListItems(state);
1915
+ state.selectedIndex = 0;
1916
+ }
1917
+ else {
1918
+ // Switching to flat view
1919
+ state.listItems = [];
1920
+ state.selectedIndex = 0;
1921
+ }
1922
+ renderList(ui, state);
1923
+ renderPreview(ui, state);
1924
+ ui.screen.render();
1925
+ }
1926
+ /**
1927
+ * Check if selection is on a document (for Enter key handling)
1928
+ */
1929
+ export function isDocumentSelected(state) {
1930
+ if (!state.groupByProject) {
1931
+ return state.filtered.length > 0;
1932
+ }
1933
+ const item = state.listItems[state.selectedIndex];
1934
+ return item?.type === 'document';
1935
+ }
1936
+ /**
1937
+ * Get the selected source for full view (handles both modes)
1938
+ */
1939
+ export function getSelectedSourceForFullView(state) {
1940
+ return getSelectedSource(state);
1941
+ }
1942
+ // ============================================================================
1943
+ // Pending Proposals
1944
+ // ============================================================================