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 { backupCommand, restoreDbCommand, listBackupsCommand } from './commands/backup-cmd';
|
||||||
import { graphsCommand, useCommand, initCommand } from './commands/graphs';
|
import { graphsCommand, useCommand, initCommand } from './commands/graphs';
|
||||||
import { smartSearchCommand, ssCommand, whatCommand, contextAwareCommand } from './commands/smart';
|
import { smartSearchCommand, ssCommand, whatCommand, contextAwareCommand } from './commands/smart';
|
||||||
|
import { tuiCommand, uiCommand } from './commands/tui';
|
||||||
import { closeDb } from '../core/db';
|
import { closeDb } from '../core/db';
|
||||||
import { migrateOldDatabase } from '../core/db';
|
import { migrateOldDatabase } from '../core/db';
|
||||||
|
|
||||||
@@ -72,6 +73,8 @@ program.addCommand(smartSearchCommand);
|
|||||||
program.addCommand(ssCommand);
|
program.addCommand(ssCommand);
|
||||||
program.addCommand(whatCommand);
|
program.addCommand(whatCommand);
|
||||||
program.addCommand(contextAwareCommand);
|
program.addCommand(contextAwareCommand);
|
||||||
|
program.addCommand(tuiCommand);
|
||||||
|
program.addCommand(uiCommand);
|
||||||
|
|
||||||
// Check for old database migration
|
// Check for old database migration
|
||||||
migrateOldDatabase();
|
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