Add interactive TUI dashboard (Milestone 11)
- Lightweight TUI using Node.js readline (no extra deps) - Browse nodes with vim-style navigation (j/k/arrows) - Real-time search with / key - Detail view with connections - Filter by kind (1-4 keys) - CLI: cortex tui, cortex ui
This commit is contained in:
38
src/cli/commands/tui.ts
Normal file
38
src/cli/commands/tui.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
392
src/tui/index.ts
Normal file
392
src/tui/index.ts
Normal file
@@ -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<void> {
|
||||
// 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<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user