lorehub
Version:
Capture and surface the collective wisdom of your codebase
536 lines • 27.4 kB
JavaScript
import React, { useState, useEffect } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { Table } from './Table.js';
import TextInput from 'ink-text-input';
import { Help } from './Help.js';
import { ConfirmDialog } from './ConfirmDialog.js';
import { SimilarLoresView } from './SimilarLoresView.js';
import { TruncatedText } from './TruncatedText.js';
import { AlternativeScreenView } from './AlternativeScreenView.js';
import { useTerminalDimensions } from '../hooks/useTerminalDimensions.js';
import { ConfigManager } from '../../core/config.js';
export function LoresView({ db, realmPath, initialQuery = '', searchMode: initialSearchMode, type, province, limit = 100, filterRealmPath, currentRealmOnly, }) {
const { exit } = useApp();
const { columns, rows } = useTerminalDimensions();
const config = ConfigManager.getInstance();
const [lores, setLores] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filteredLores, setFilteredLores] = useState([]);
const [showHelp, setShowHelp] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteSuccess, setDeleteSuccess] = useState(false);
const [similarLoresCounts, setSimilarLoresCounts] = useState(new Map());
const [loadingSimilarCounts, setLoadingSimilarCounts] = useState(false);
const [showSimilarLores, setShowSimilarLores] = useState(false);
const [searchMode, setSearchMode] = useState(initialSearchMode || config.get('searchMode') || 'literal');
const [isFilterLoading, setIsFilterLoading] = useState(false);
useInput((input, key) => {
if (showHelp) {
if (input === '?' || key.escape) {
setShowHelp(false);
}
return;
}
if (showDeleteConfirm) {
// Let ConfirmDialog handle input
return;
}
if (isSearching) {
if (key.escape) {
setIsSearching(false);
setSearchTerm('');
setFilteredLores(lores);
}
else if (key.return) {
setIsSearching(false);
}
else if (key.backspace || key.delete) {
const newSearchTerm = searchTerm.slice(0, -1);
setSearchTerm(newSearchTerm);
filterLores(newSearchTerm); // async but we don't await
}
else if (input && input.length === 1 && !key.ctrl && !key.meta) {
const newSearchTerm = searchTerm + input;
setSearchTerm(newSearchTerm);
filterLores(newSearchTerm); // async but we don't await
}
}
else {
if (input === 'q' || key.escape) {
exit();
// Ensure process exits immediately
process.exit(0);
}
else if (input === '/') {
setIsSearching(true);
setSearchTerm('');
}
else if (input === '?') {
setShowHelp(true);
}
else if (input === 'd' && filteredLores.length > 0) {
setShowDeleteConfirm(true);
}
else if (input === 's' && filteredLores.length > 0) {
setShowSimilarLores(true);
}
else if (input === 'm' || input === 'M') {
// Switch search mode
const modes = ['literal', 'semantic', 'hybrid'];
const currentIndex = modes.indexOf(searchMode);
const newMode = modes[(currentIndex + 1) % modes.length];
if (newMode) {
setSearchMode(newMode);
// Save preference
config.set('searchMode', newMode);
}
}
else if (key.upArrow || input === 'k') {
if (selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
}
else if (key.downArrow || input === 'j') {
if (selectedIndex < filteredLores.length - 1) {
setSelectedIndex(selectedIndex + 1);
}
}
else if (key.return && filteredLores.length > 0) {
// Enter pressed - could open details or perform action
const selectedLore = filteredLores[selectedIndex];
if (selectedLore) {
// For now, just log - could open details view
console.log('Selected:', selectedLore.content);
}
}
}
});
// Helper function to get status letter
const getStatusLetter = (status) => {
switch (status) {
case 'living': return 'L';
case 'archived': return 'A';
case 'whispered': return 'W';
case 'proclaimed': return 'P';
default: return '?';
}
};
const filterLores = async (term) => {
if (!term) {
setFilteredLores(lores);
return;
}
// For literal search, use substring matching
if (searchMode === 'literal') {
const lowerTerm = term.toLowerCase();
const filtered = lores.filter(lore => lore.content.toLowerCase().includes(lowerTerm) ||
lore.type.toLowerCase().includes(lowerTerm) ||
lore.sigils.some((sigil) => sigil.toLowerCase().includes(lowerTerm)) ||
lore.realmName.toLowerCase().includes(lowerTerm));
setFilteredLores(filtered);
setSelectedIndex(0);
}
else {
// For semantic or hybrid search, query the database
setIsFilterLoading(true);
try {
let results = [];
// Get all realms we're searching
const realms = currentRealmOnly ?
[db.findRealmByPath(realmPath)].filter(Boolean) :
(filterRealmPath ? [db.findRealmByPath(filterRealmPath)].filter(Boolean) : db.listRealms());
for (const realm of realms) {
if (!realm)
continue;
if (searchMode === 'semantic') {
const semanticResults = await db.semanticSearchLores(term, {
realmId: realm.id,
includeScore: true,
threshold: config.get('semanticSearchThreshold') || 0.5
});
results.push(...semanticResults.map(f => ({
...f,
realmName: realm.name,
realmPath: realm.path,
isCurrentRealm: realmPath === realm.path
})));
}
else if (searchMode === 'hybrid') {
// Hybrid: combine literal and semantic
const literalResults = db.searchLores(realm.id, term);
const semanticResults = await db.semanticSearchLores(term, {
realmId: realm.id,
includeScore: true,
threshold: config.get('semanticSearchThreshold') || 0.5
});
// Merge and deduplicate
const loreMap = new Map();
literalResults.forEach(lore => loreMap.set(lore.id, {
...lore,
realmName: realm.name,
realmPath: realm.path,
isCurrentRealm: realmPath === realm.path,
similarity: 1.0 // Perfect match for literal
}));
semanticResults.forEach(lore => {
if (!loreMap.has(lore.id)) {
loreMap.set(lore.id, {
...lore,
realmName: realm.name,
realmPath: realm.path,
isCurrentRealm: realmPath === realm.path
});
}
});
results.push(...Array.from(loreMap.values()));
}
}
// Filter out archived
results = results.filter(f => f.status !== 'archived');
// Sort by similarity (if available) then by date
results.sort((a, b) => {
if (a.similarity !== undefined && b.similarity !== undefined) {
return b.similarity - a.similarity;
}
return b.createdAt.getTime() - a.createdAt.getTime();
});
setFilteredLores(results);
setSelectedIndex(0);
}
catch (error) {
console.error('Search error:', error);
setFilteredLores([]);
}
finally {
setIsFilterLoading(false);
}
}
};
useEffect(() => {
async function loadLores() {
const currentRealm = db.findRealmByPath(realmPath);
const realms = db.listRealms();
// Filter realms based on options
let realmsToSearch = realms;
if (currentRealmOnly) {
if (!currentRealm) {
setLores([]);
setLoading(false);
return;
}
realmsToSearch = [currentRealm];
}
else if (filterRealmPath) {
const specificRealm = db.findRealmByPath(filterRealmPath);
if (!specificRealm) {
setLores([]);
setLoading(false);
return;
}
realmsToSearch = [specificRealm];
}
let results = [];
// Get lores from selected realms
for (const proj of realmsToSearch) {
let realmLores = [];
if (initialQuery) {
// Search mode based on searchMode state
if (searchMode === 'semantic') {
realmLores = await db.semanticSearchLores(initialQuery, {
realmId: proj.id,
includeScore: true,
threshold: config.get('semanticSearchThreshold') || 0.5
});
}
else if (searchMode === 'hybrid') {
// Hybrid search - combine literal and semantic
const literalResults = db.searchLores(proj.id, initialQuery);
const semanticResults = await db.semanticSearchLores(initialQuery, {
realmId: proj.id,
includeScore: true,
threshold: config.get('semanticSearchThreshold') || 0.5
});
// Merge and deduplicate
const loreMap = new Map();
literalResults.forEach(lore => loreMap.set(lore.id, lore));
semanticResults.forEach(lore => {
if (!loreMap.has(lore.id)) {
loreMap.set(lore.id, lore);
}
});
realmLores = Array.from(loreMap.values());
}
else {
// Literal search (default)
realmLores = db.searchLores(proj.id, initialQuery);
}
}
else if (type) {
realmLores = db.listLoresByType(proj.id, type);
}
else if (province) {
realmLores = db.listLoresByProvince(proj.id, province);
}
else {
realmLores = db.listLoresByRealm(proj.id);
}
// Add realm info to each lore
results.push(...realmLores.map(f => ({
...f,
realmName: proj.name,
realmPath: proj.path,
isCurrentRealm: currentRealm?.id === proj.id
})));
}
// Apply additional filters
if (initialQuery && type) {
results = results.filter(f => f.type === type);
}
if (initialQuery && province) {
results = results.filter(f => f.provinces.includes(province));
}
// Filter out archived lores unless specifically requested
results = results.filter(f => f.status !== 'archived');
// Sort by current realm first, then by date
results.sort((a, b) => {
// Prioritize current realm
if (a.isCurrentRealm && !b.isCurrentRealm)
return -1;
if (!a.isCurrentRealm && b.isCurrentRealm)
return 1;
// Then by date
return b.createdAt.getTime() - a.createdAt.getTime();
});
// Apply limit
if (results.length > limit) {
results = results.slice(0, limit);
}
setLores(results);
setFilteredLores(results);
setLoading(false);
}
loadLores();
}, [db, realmPath, initialQuery, type, province, limit, filterRealmPath, currentRealmOnly, searchMode, config]);
// Load similar lores counts
useEffect(() => {
const loadSimilarCounts = async () => {
if (lores.length === 0)
return;
setLoadingSimilarCounts(true);
const counts = new Map();
// Load counts in batches to avoid overwhelming the system
const batchSize = 10;
for (let i = 0; i < lores.length && i < limit; i += batchSize) {
const batch = lores.slice(i, i + batchSize);
await Promise.all(batch.map(async (lore) => {
try {
const similar = await db.findSimilarLores(lore.id, {
limit: 10,
threshold: 0.5
});
counts.set(lore.id, similar.length);
}
catch (error) {
counts.set(lore.id, 0);
}
}));
}
setSimilarLoresCounts(counts);
setLoadingSimilarCounts(false);
};
loadSimilarCounts();
}, [lores, db, limit]);
// Always render inside AlternativeScreenView
if (loading) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(Box, { flexDirection: "column", padding: 1 },
React.createElement(Text, null, "Loading lores..."))));
}
if (lores.length === 0) {
return (React.createElement(Box, { flexDirection: "column" },
React.createElement(Text, { color: "yellow" }, initialQuery
? `No lores found matching "${initialQuery}"`
: 'No lores found'),
type && React.createElement(Text, { dimColor: true },
"Filter: type = ",
type),
province && React.createElement(Text, { dimColor: true },
"Filter: province = ",
province),
!initialQuery && (React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { dimColor: true }, "Try adding lores with: lh add \"Your lore here\"")))));
}
// Calculate dimensions early so we can use them in items mapping
// Header is now dynamic - base 1 line + additional lines for filters/search
const headerHeight = 1 + (deleteSuccess ? 1 : 0) + ((type || province) ? 1 : 0) + (isSearching ? 1 : 0);
const footerHeight = 2;
const contentHeight = rows - headerHeight - footerHeight - 1; // -1 for padding
// Split width: 70% for lores list, 30% for details on wide screens
// On narrow screens, use 60/40 split
const loresWidth = columns > 120 ? Math.floor(columns * 0.7) : Math.floor(columns * 0.6);
const detailsWidth = columns - loresWidth - 3; // -3 for margins and borders
// Calculate visible rows for scrolling
const visibleRows = Math.max(10, contentHeight - 6); // Reserve space for headers and padding
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(visibleRows / 2), filteredLores.length - visibleRows));
const endIndex = Math.min(startIndex + visibleRows, filteredLores.length);
// Prepare data for our table with scrolling
const visibleLores = filteredLores.slice(startIndex, endIndex);
const tableData = visibleLores.map((lore, index) => {
const actualIndex = startIndex + index;
const similarCount = similarLoresCounts.get(lore.id) || 0;
const isSelected = actualIndex === selectedIndex;
// Calculate max content length based on available width
const showDistance = (searchMode === 'semantic' || searchMode === 'hybrid') && lore.similarity !== undefined;
const contentMaxLength = Math.max(30, loresWidth - (showDistance ? 42 : 35)); // Reserve space for columns
const content = lore.content.length > contentMaxLength
? lore.content.substring(0, contentMaxLength - 3) + '...'
: lore.content;
const row = {
'': isSelected ? '→' : ' ',
'R': lore.isCurrentRealm ? '•' : ' ',
};
// Add distance column if in semantic/hybrid mode
if (showDistance) {
// Convert similarity back to L2 distance for display
// similarity = 1 / (1 + distance), so distance = (1 / similarity) - 1
// Lower distance = more similar
let distance = '-';
if (lore.similarity !== undefined && lore.similarity > 0) {
const l2Distance = (1 / lore.similarity) - 1;
distance = l2Distance.toFixed(2);
}
row['Dist'] = distance;
}
row['Sim'] = similarCount > 0 ? `${similarCount}≈` : '-';
row['Type'] = lore.type.substring(0, 3).toUpperCase();
row['S'] = getStatusLetter(lore.status);
row['Content'] = content;
return row;
});
const selectedLore = filteredLores[selectedIndex];
const handleDelete = async () => {
const loreToDelete = filteredLores[selectedIndex];
if (loreToDelete) {
await db.softDeleteLore(loreToDelete.id);
// Remove from local state
const newLores = lores.filter(f => f.id !== loreToDelete.id);
const newFilteredLores = filteredLores.filter(f => f.id !== loreToDelete.id);
setLores(newLores);
setFilteredLores(newFilteredLores);
setShowDeleteConfirm(false);
setDeleteSuccess(true);
// Reset success message after 2 seconds
setTimeout(() => setDeleteSuccess(false), 2000);
// Adjust selected index if needed
if (selectedIndex >= newFilteredLores.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
}
};
// Show help screen if requested
if (showHelp) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(Help, { context: initialQuery ? 'search' : 'list' })));
}
// Show confirmation dialog instead of normal view
if (showDeleteConfirm && selectedLore) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(Box, { flexDirection: "column", height: 20, justifyContent: "center", alignItems: "center" },
React.createElement(ConfirmDialog, { message: `Delete lore: "${selectedLore.content.substring(0, 50)}${selectedLore.content.length > 50 ? '...' : ''}"?`, onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false), dangerous: true }))));
}
// Show similar lores view
if (showSimilarLores && selectedLore) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(SimilarLoresView, { db: db, lore: selectedLore, onBack: () => setShowSimilarLores(false) })));
}
// Full screen container
return (React.createElement(AlternativeScreenView, null,
React.createElement(Box, { flexDirection: "column", height: rows - 1 },
React.createElement(Box, { flexDirection: "column", flexShrink: 0 },
React.createElement(Box, { justifyContent: "space-between" },
React.createElement(Text, { bold: true },
initialQuery
? `Found ${filteredLores.length} lore${filteredLores.length !== 1 ? 's' : ''} matching "${initialQuery}"`
: `Found ${filteredLores.length} lore${filteredLores.length !== 1 ? 's' : ''}`,
searchTerm && ` (filtered: "${searchTerm}")`),
React.createElement(Text, { dimColor: true },
"Mode: ",
searchMode,
" | ? help")),
(deleteSuccess || type || province || isSearching) && (React.createElement(Box, { flexDirection: "column" },
deleteSuccess && React.createElement(Text, { color: "green" }, "\u2713 Lore deleted (archived)"),
(type || province) && (React.createElement(Text, { dimColor: true },
"Filters: ",
[
type && `type=${type}`,
province && `province=${province}`
].filter(Boolean).join(', '))),
isSearching && (React.createElement(Box, null,
React.createElement(Text, { color: "cyan" }, "/"),
React.createElement(TextInput, { value: searchTerm, onChange: () => { } }),
isFilterLoading && React.createElement(Text, { dimColor: true }, " (searching...)")))))),
React.createElement(Box, { flexDirection: "row", height: contentHeight, overflow: "hidden" },
React.createElement(Box, { flexDirection: "column", width: loresWidth, marginRight: 2 },
React.createElement(Text, { bold: true, dimColor: true }, initialQuery ? 'Search Results' : 'Lores'),
React.createElement(Box, { width: "100%", flexDirection: "column", marginTop: 0 }, tableData.length > 0 ? (React.createElement(React.Fragment, null,
React.createElement(Table, { data: tableData }),
filteredLores.length > visibleRows && (React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { dimColor: true },
"Showing ",
startIndex + 1,
"-",
endIndex,
" of ",
filteredLores.length,
" | Use \u2191\u2193 or j/k to navigate"))))) : (React.createElement(Text, { dimColor: true }, "No lores found")))),
React.createElement(Box, { flexDirection: "column", width: detailsWidth, overflow: "hidden" },
React.createElement(Text, { bold: true, dimColor: true }, "Details"),
selectedLore && (React.createElement(Box, { flexDirection: "column", marginTop: 1 },
React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
React.createElement(Text, { dimColor: true },
"Realm: ",
selectedLore.realmName,
selectedLore.isCurrentRealm ? ' •' : ''),
React.createElement(Text, { dimColor: true },
"Type: ",
selectedLore.type,
" | Status: ",
selectedLore.status,
" | Confidence: ",
selectedLore.confidence,
"%"),
React.createElement(Text, { dimColor: true },
"Created: ",
selectedLore.createdAt.toLocaleDateString(),
" ",
selectedLore.createdAt.toLocaleTimeString()),
selectedLore.similarity !== undefined && (React.createElement(Text, { dimColor: true },
"Match: ",
Math.round(selectedLore.similarity * 100),
"% (",
searchMode,
" search)")),
React.createElement(Text, { dimColor: true },
"Sigils: ",
selectedLore.sigils.length > 0 ? selectedLore.sigils.join(', ') : 'none'),
React.createElement(Text, { dimColor: true },
"Provinces: ",
selectedLore.provinces.length > 0 ? selectedLore.provinces.join(', ') : 'none')),
React.createElement(Text, { dimColor: true }, '─'.repeat(Math.min(detailsWidth - 2, 50))),
React.createElement(Box, { flexDirection: "column", marginTop: 1 },
React.createElement(Box, { marginBottom: 1 },
React.createElement(Text, { wrap: "wrap" }, selectedLore.content)),
selectedLore.why && (React.createElement(Box, { marginTop: 1, flexDirection: "column" },
React.createElement(Box, { marginBottom: 0 },
React.createElement(Text, { bold: true, dimColor: true }, "Why:")),
React.createElement(Box, null,
React.createElement(Text, { wrap: "wrap", dimColor: true }, selectedLore.why))))))))),
React.createElement(Box, { height: 2, marginTop: 1 },
React.createElement(Text, { dimColor: true }, isSearching
? 'Type to filter | Enter: confirm | Esc: cancel'
: 'q: quit | ↑↓: navigate | /: filter | d: delete | s: similar | m: mode | ?: help')))));
}
//# sourceMappingURL=LoresView.js.map