diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts new file mode 100644 index 0000000..6343bca --- /dev/null +++ b/src/cli/commands/tui.ts @@ -0,0 +1,38 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; + +export const tuiCommand = new Command('tui') + .description('Launch interactive terminal user interface') + .action(async () => { + try { + // Check if running in a TTY + if (!process.stdin.isTTY) { + console.error(chalk.red('Error: TUI requires an interactive terminal')); + process.exit(1); + } + + const { launchTui } = await import('../../tui'); + await launchTui(); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Alias +export const uiCommand = new Command('ui') + .description('Alias for tui') + .action(async () => { + try { + if (!process.stdin.isTTY) { + console.error(chalk.red('Error: UI requires an interactive terminal')); + process.exit(1); + } + + const { launchTui } = await import('../../tui'); + await launchTui(); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 4201be4..adf3ba7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -24,6 +24,7 @@ import { importCommand } from './commands/import'; import { backupCommand, restoreDbCommand, listBackupsCommand } from './commands/backup-cmd'; import { graphsCommand, useCommand, initCommand } from './commands/graphs'; import { smartSearchCommand, ssCommand, whatCommand, contextAwareCommand } from './commands/smart'; +import { tuiCommand, uiCommand } from './commands/tui'; import { closeDb } from '../core/db'; import { migrateOldDatabase } from '../core/db'; @@ -72,6 +73,8 @@ program.addCommand(smartSearchCommand); program.addCommand(ssCommand); program.addCommand(whatCommand); program.addCommand(contextAwareCommand); +program.addCommand(tuiCommand); +program.addCommand(uiCommand); // Check for old database migration migrateOldDatabase(); diff --git a/src/tui/index.ts b/src/tui/index.ts new file mode 100644 index 0000000..aaac671 --- /dev/null +++ b/src/tui/index.ts @@ -0,0 +1,392 @@ +import * as readline from 'readline'; +import chalk from 'chalk'; +import { listNodes, query, getNode } from '../core/store'; +import { getConnections } from '../core/graph'; +import { getActiveGraph } from '../core/graphs'; +import { Node } from '../types'; + +interface TuiState { + mode: 'browse' | 'search' | 'detail' | 'help'; + nodes: Node[]; + selected: number; + searchQuery: string; + detailNode: Node | null; + scroll: number; +} + +const ITEMS_PER_PAGE = 10; + +let state: TuiState = { + mode: 'browse', + nodes: [], + selected: 0, + searchQuery: '', + detailNode: null, + scroll: 0, +}; + +/** + * Launch the TUI dashboard + */ +export async function launchTui(): Promise { + // Load initial nodes + state.nodes = listNodes({ limit: 100, includeStale: false }); + + // Setup terminal + setupTerminal(); + render(); + + // Setup input handling + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + + // Enable raw mode for keystroke handling + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + + process.stdin.on('data', handleKeypress); + + // Initial render + render(); + + // Keep process alive + await new Promise((resolve) => { + process.on('SIGINT', () => { + cleanup(); + resolve(); + }); + }); +} + +function setupTerminal(): void { + // Clear screen and hide cursor + process.stdout.write('\x1b[2J'); + process.stdout.write('\x1b[?25l'); + process.stdout.write('\x1b[H'); +} + +function cleanup(): void { + // Show cursor and clear screen + process.stdout.write('\x1b[?25h'); + process.stdout.write('\x1b[2J'); + process.stdout.write('\x1b[H'); +} + +async function handleKeypress(data: Buffer): Promise { + const key = data.toString(); + + // Global keys + if (key === 'q' || key === '\x03') { // q or Ctrl+C + cleanup(); + process.exit(0); + } + + if (key === '?') { + state.mode = state.mode === 'help' ? 'browse' : 'help'; + render(); + return; + } + + if (state.mode === 'search') { + await handleSearchInput(key); + } else if (state.mode === 'browse') { + await handleBrowseInput(key); + } else if (state.mode === 'detail') { + handleDetailInput(key); + } else if (state.mode === 'help') { + if (key === '\x1b' || key === '\r') { // Escape or Enter + state.mode = 'browse'; + render(); + } + } +} + +async function handleBrowseInput(key: string): Promise { + switch (key) { + case '\x1b[A': // Up arrow + case 'k': + if (state.selected > 0) { + state.selected--; + if (state.selected < state.scroll) { + state.scroll = state.selected; + } + } + break; + + case '\x1b[B': // Down arrow + case 'j': + if (state.selected < state.nodes.length - 1) { + state.selected++; + if (state.selected >= state.scroll + ITEMS_PER_PAGE) { + state.scroll = state.selected - ITEMS_PER_PAGE + 1; + } + } + break; + + case '\r': // Enter + if (state.nodes[state.selected]) { + state.detailNode = state.nodes[state.selected]; + state.mode = 'detail'; + } + break; + + case '/': + state.mode = 'search'; + state.searchQuery = ''; + break; + + case 'r': + // Refresh + state.nodes = listNodes({ limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + break; + + case '1': + state.nodes = listNodes({ kind: 'memory', limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + break; + + case '2': + state.nodes = listNodes({ kind: 'component', limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + break; + + case '3': + state.nodes = listNodes({ kind: 'task', limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + break; + + case '4': + state.nodes = listNodes({ kind: 'decision', limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + break; + } + + render(); +} + +async function handleSearchInput(key: string): Promise { + if (key === '\x1b') { // Escape + state.mode = 'browse'; + state.searchQuery = ''; + state.nodes = listNodes({ limit: 100, includeStale: false }); + state.selected = 0; + state.scroll = 0; + } else if (key === '\r') { // Enter + state.mode = 'browse'; + } else if (key === '\x7f') { // Backspace + state.searchQuery = state.searchQuery.slice(0, -1); + if (state.searchQuery.length > 0) { + const results = await query(state.searchQuery, { limit: 50 }); + state.nodes = results.map(r => r.node); + } else { + state.nodes = listNodes({ limit: 100, includeStale: false }); + } + state.selected = 0; + state.scroll = 0; + } else if (key.length === 1 && key >= ' ') { + state.searchQuery += key; + if (state.searchQuery.length >= 2) { + const results = await query(state.searchQuery, { limit: 50 }); + state.nodes = results.map(r => r.node); + } + state.selected = 0; + state.scroll = 0; + } + + render(); +} + +function handleDetailInput(key: string): void { + if (key === '\x1b' || key === 'q' || key === '\r') { // Escape, q, or Enter + state.mode = 'browse'; + state.detailNode = null; + } else if (key === '\x1b[A' || key === 'k') { + // Scroll up in detail view + // Could implement scrolling for long content + } else if (key === '\x1b[B' || key === 'j') { + // Scroll down in detail view + } + + render(); +} + +function render(): void { + // Clear screen + process.stdout.write('\x1b[2J'); + process.stdout.write('\x1b[H'); + + const width = process.stdout.columns || 80; + const height = process.stdout.rows || 24; + const activeGraph = getActiveGraph(); + + // Header + const header = ` CORTEX TUI `; + const graphInfo = ` Graph: ${activeGraph} `; + const headerLine = chalk.bgBlue.white(header) + ' '.repeat(width - header.length - graphInfo.length) + chalk.bgGreen.black(graphInfo); + console.log(headerLine); + console.log(chalk.dim('─'.repeat(width))); + + if (state.mode === 'help') { + renderHelp(width, height); + return; + } + + if (state.mode === 'detail' && state.detailNode) { + renderDetail(state.detailNode, width, height); + return; + } + + // Search bar + if (state.mode === 'search') { + console.log(chalk.cyan('🔍 Search: ') + state.searchQuery + chalk.dim('_')); + } else { + console.log(chalk.dim('🔍 Press / to search')); + } + console.log(); + + // Filter info + console.log(chalk.dim(`Showing ${state.nodes.length} nodes [1]Memory [2]Component [3]Task [4]Decision`)); + console.log(); + + // Node list + const visibleNodes = state.nodes.slice(state.scroll, state.scroll + ITEMS_PER_PAGE); + + for (let i = 0; i < ITEMS_PER_PAGE; i++) { + const nodeIndex = state.scroll + i; + const node = visibleNodes[i]; + + if (!node) { + console.log(); + continue; + } + + const isSelected = nodeIndex === state.selected; + const prefix = isSelected ? chalk.cyan('▶ ') : ' '; + const kindColor = getKindColor(node.kind); + const title = node.title.slice(0, width - 30); + const id = node.id.slice(0, 8); + + let line = `${prefix}${chalk.dim(id)} [${kindColor(node.kind.padEnd(9))}] ${isSelected ? chalk.bold(title) : title}`; + + if (node.status) { + line += chalk.dim(` (${node.status})`); + } + + console.log(line); + } + + // Scrollbar indicator + if (state.nodes.length > ITEMS_PER_PAGE) { + const scrollPercent = Math.floor((state.selected / state.nodes.length) * 100); + console.log(); + console.log(chalk.dim(` Showing ${state.scroll + 1}-${Math.min(state.scroll + ITEMS_PER_PAGE, state.nodes.length)} of ${state.nodes.length} (${scrollPercent}%)`)); + } + + // Footer + console.log(); + console.log(chalk.dim('─'.repeat(width))); + console.log(chalk.dim('[↑↓/jk]Navigate [Enter]View [/]Search [r]Refresh [?]Help [q]Quit')); +} + +function renderDetail(node: Node, width: number, height: number): void { + console.log(chalk.bold.cyan(`\n ${node.title}\n`)); + console.log(chalk.dim(` ID: ${node.id}`)); + console.log(` Kind: ${getKindColor(node.kind)(node.kind)}`); + if (node.status) { + console.log(` Status: ${node.status}`); + } + if (node.tags.length > 0) { + console.log(` Tags: ${chalk.yellow(node.tags.join(', '))}`); + } + console.log(` Created: ${new Date(node.createdAt).toLocaleString()}`); + console.log(` Updated: ${new Date(node.updatedAt).toLocaleString()}`); + + console.log(chalk.dim('\n ─── Content ───\n')); + + if (node.content) { + const contentLines = node.content.split('\n').slice(0, height - 20); + for (const line of contentLines) { + console.log(` ${line.slice(0, width - 4)}`); + } + if (node.content.split('\n').length > height - 20) { + console.log(chalk.dim(' ... (truncated)')); + } + } else { + console.log(chalk.dim(' (no content)')); + } + + // Connections + const connections = getConnections(node.id); + if (connections.outgoing.length > 0 || connections.incoming.length > 0) { + console.log(chalk.dim('\n ─── Connections ───\n')); + + if (connections.outgoing.length > 0) { + console.log(chalk.dim(' Outgoing:')); + for (const conn of connections.outgoing.slice(0, 5)) { + console.log(` → ${conn.type}: ${conn.node.title}`); + } + if (connections.outgoing.length > 5) { + console.log(chalk.dim(` ... and ${connections.outgoing.length - 5} more`)); + } + } + + if (connections.incoming.length > 0) { + console.log(chalk.dim(' Incoming:')); + for (const conn of connections.incoming.slice(0, 5)) { + console.log(` ← ${conn.type}: ${conn.node.title}`); + } + if (connections.incoming.length > 5) { + console.log(chalk.dim(` ... and ${connections.incoming.length - 5} more`)); + } + } + } + + console.log(chalk.dim('\n' + '─'.repeat(width))); + console.log(chalk.dim('[Esc/q]Back')); +} + +function renderHelp(width: number, height: number): void { + console.log(chalk.bold.cyan('\n Keyboard Shortcuts\n')); + + const shortcuts = [ + ['↑ / k', 'Move selection up'], + ['↓ / j', 'Move selection down'], + ['Enter', 'View node details'], + ['/', 'Start search'], + ['Esc', 'Cancel / Go back'], + ['r', 'Refresh list'], + ['1', 'Show only memories'], + ['2', 'Show only components'], + ['3', 'Show only tasks'], + ['4', 'Show only decisions'], + ['?', 'Toggle help'], + ['q', 'Quit'], + ]; + + for (const [key, desc] of shortcuts) { + console.log(` ${chalk.cyan(key.padEnd(12))} ${desc}`); + } + + console.log(chalk.dim('\n' + '─'.repeat(width))); + console.log(chalk.dim('[Enter/Esc]Close help')); +} + +function getKindColor(kind: string): chalk.Chalk { + switch (kind) { + case 'memory': return chalk.blue; + case 'component': return chalk.green; + case 'task': return chalk.yellow; + case 'decision': return chalk.magenta; + default: return chalk.white; + } +}