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:
2026-02-03 11:30:44 +01:00
parent f891f37bde
commit b1c62c5da9
3 changed files with 433 additions and 0 deletions

38
src/cli/commands/tui.ts Normal file
View 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);
}
});

View File

@@ -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
View 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;
}
}