feat(tui): add missing command palette commands

Add missing commands to TUI command palette as listed in docs/plan.md:
- worker:<id> - Jump to worker detail view
- bead:<id> - Show all events for a bead (cross-reference view)
- file:<pattern> - Show all operations on matching files
- filter:last:<duration> - Filter to last N minutes (e.g., 5m, 1h)
- goto:<timestamp> - Jump to specific timestamp in activity stream
- export - Export current view to .fabric-replay file
- export:link - Generate shareable base64 link
- export:import - Import replay file

Also added support methods in ActivityStream:
- setTimeFilter() for time-based filtering
- scrollToTimestamp() for timestamp navigation
- Enhanced setFilter() to support beadId and filePattern

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-27 02:25:10 -04:00
parent 7e52107751
commit 6e3049dc1a
3 changed files with 485 additions and 9 deletions

View file

@ -5,7 +5,7 @@
*/
import blessed from 'blessed';
import { LogEvent, WorkerInfo, WorkerStatus } from '../types.js';
import { LogEvent, WorkerInfo, WorkerStatus, EventFilter } from '../types.js';
import { InMemoryEventStore } from '../store.js';
import { colors, getStatusColor, getThemeManager, ThemeName } from './utils/colors.js';
import { WorkerGrid } from './components/WorkerGrid.js';
@ -40,12 +40,15 @@ export interface TuiOptions {
/** Refresh interval in ms */
refreshInterval?: number;
/** CLI filter for worker/level */
filter?: EventFilter;
}
export class FabricTuiApp {
private screen: blessed.Widgets.Screen;
private store: InMemoryEventStore;
private options: Required<TuiOptions>;
private options: TuiOptions;
private isRunning = false;
// View mode
@ -94,11 +97,10 @@ export class FabricTuiApp {
constructor(store: InMemoryEventStore, options: TuiOptions = {}) {
this.store = store;
this.options = {
logPath: options.logPath || '',
maxEvents: options.maxEvents || 1000,
refreshInterval: options.refreshInterval || 100,
};
this.options = options;
if (!this.options.maxEvents) this.options.maxEvents = 1000;
if (!this.options.refreshInterval) this.options.refreshInterval = 100;
if (!this.options.logPath) this.options.logPath = '';
// Initialize theme
const themeManager = getThemeManager();
@ -141,6 +143,18 @@ export class FabricTuiApp {
// Build worker count badge with colored status indicators
let badge = ' FABRIC - Worker Activity Monitor';
// Add filter indicator if CLI filters are active
if (this.options.filter?.worker || this.options.filter?.level) {
const filterParts: string[] = [];
if (this.options.filter.worker) {
filterParts.push(`worker:${this.options.filter.worker.slice(0, 8)}`);
}
if (this.options.filter.level) {
filterParts.push(`level:${this.options.filter.level}`);
}
badge += ` {yellow-fg}[FILTER: ${filterParts.join(' ')}]{/}`;
}
if (stats.total > 0) {
badge += ' {bold}[';
@ -644,12 +658,19 @@ export class FabricTuiApp {
this.toggleAnalyticsView();
} else if (cmd === 'budget') {
this.toggleBudgetView();
} else if (cmd === 'transcript') {
this.toggleTranscriptView();
} else if (cmd === 'xref') {
this.toggleXrefView();
} else if (cmd.startsWith('filter:worker:')) {
const workerId = cmd.replace('filter:worker:', '');
this.activityStream.setFilter({ workerId });
} else if (cmd.startsWith('filter:level:')) {
const level = cmd.replace('filter:level:', '');
this.activityStream.setFilter({ level });
} else if (cmd.startsWith('filter:last:')) {
const duration = cmd.replace('filter:last:', '');
this.handleTimeFilter(duration);
} else if (cmd === 'theme' || cmd === 'theme:toggle') {
this.toggleTheme();
} else if (cmd === 'theme:dark') {
@ -686,9 +707,367 @@ export class FabricTuiApp {
this.screen.render();
}, 3000);
}
} else if (cmd === 'export' || cmd === 'export:file') {
this.handleExportToFile();
} else if (cmd === 'export:link') {
this.handleExportLink();
} else if (cmd === 'export:import') {
this.handleExportImport();
} else if (cmd.startsWith('worker:')) {
const workerId = cmd.replace('worker:', '').trim();
this.handleJumpToWorker(workerId);
} else if (cmd.startsWith('bead:')) {
const beadId = cmd.replace('bead:', '').trim();
this.handleShowBeadEvents(beadId);
} else if (cmd.startsWith('file:')) {
const pattern = cmd.replace('file:', '').trim();
this.handleShowFileOperations(pattern);
} else if (cmd.startsWith('goto:')) {
const timestamp = cmd.replace('goto:', '').trim();
this.handleJumpToTimestamp(timestamp);
}
}
/**
* Handle export to file command
*/
private handleExportToFile(): void {
const events = this.store.queryOrdered();
if (events.length === 0) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {yellow-fg}No events to export{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
try {
const { exportToJson, generateExportFilename } = require('../utils/replayExport.js');
const fs = require('fs');
const path = require('path');
const os = require('os');
const exportData = {
version: '1.0',
exportedAt: Date.now(),
eventCount: events.length,
events: events,
metadata: {
sessionStart: Math.min(...events.map((e: LogEvent) => e.ts)),
sessionEnd: Math.max(...events.map((e: LogEvent) => e.ts)),
workerCount: new Set(events.map((e: LogEvent) => e.worker)).size,
},
};
const filename = `session-${new Date(exportData.metadata.sessionStart).toISOString().split('T')[0]}.fabric-replay`;
const exportPath = path.join(os.homedir(), 'Downloads', filename);
fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2), 'utf-8');
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {green-fg}Exported ${events.length} events to ${filename}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
} catch (err) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {red-fg}Export failed: ${(err as Error).message}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
}
}
/**
* Handle export shareable link command (shows message for TUI)
*/
private handleExportLink(): void {
const events = this.store.queryOrdered();
if (events.length === 0) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {yellow-fg}No events to export{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
try {
const { exportToBase64 } = require('../utils/replayExport.js');
const base64Data = exportToBase64(events);
// In TUI mode, we can't copy to clipboard easily, so show the data
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {yellow-fg}Shareable link generated (web mode only). See logs.{/}');
this.screen.render();
// Log the shareable URL format
console.log('\n=== Shareable Replay Link ===');
console.log('Web UI URL format:');
console.log(`https://fabric.example.com/?replay=${base64Data.substring(0, 50)}...`);
console.log('Full base64 data logged to ~/.fabric/last-export.txt\n');
// Save to file for reference
const fs = require('fs');
const path = require('path');
const os = require('os');
const exportPath = path.join(os.homedir(), '.fabric', 'last-export.txt');
fs.mkdirSync(path.join(os.homedir(), '.fabric'), { recursive: true });
fs.writeFileSync(exportPath, base64Data, 'utf-8');
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
} catch (err) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {red-fg}Export failed: ${(err as Error).message}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
}
}
/**
* Handle import replay command
*/
private handleExportImport(): void {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {yellow-fg}Import: Use "fabric replay --file <path>" CLI command{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 4000);
}
/**
* Parse duration string (e.g., "5m", "1h", "30s") to milliseconds
*/
private parseDuration(duration: string): number | null {
const match = duration.match(/^(\d+)([smh])$/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
default: return null;
}
}
/**
* Handle time filter command (e.g., "5m", "1h", "30s")
*/
private handleTimeFilter(duration: string): void {
const ms = this.parseDuration(duration);
if (ms === null) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {red-fg}Invalid duration: ${duration}. Use format like 5m, 1h, 30s{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
return;
}
const cutoffTime = Date.now() - ms;
this.activityStream.setTimeFilter(cutoffTime);
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {cyan-fg}Filtered to last ${duration}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
}
/**
* Handle jump to worker command
*/
private handleJumpToWorker(workerId: string): void {
if (!workerId) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {red-fg}Worker ID required{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
// Find worker by partial ID match
const workers = this.store.getWorkers();
const matchedWorker = workers.find(w => w.id.startsWith(workerId) || w.id.includes(workerId));
if (!matchedWorker) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {red-fg}Worker not found: ${workerId}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
// Show worker detail panel
this.showWorkerDetail(matchedWorker);
// Set filter to this worker
this.activityStream.setFilter({ workerId: matchedWorker.id });
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {green-fg}Jumped to worker: ${matchedWorker.id.slice(0, 8)}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 1500);
}
/**
* Handle show bead events command
*/
private handleShowBeadEvents(beadId: string): void {
if (!beadId) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {red-fg}Bead ID required{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
// Set filter to show events for this bead
this.activityStream.setFilter({ beadId });
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {cyan-fg}Showing events for bead: ${beadId}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
}
/**
* Handle show file operations command
*/
private handleShowFileOperations(pattern: string): void {
if (!pattern) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(' {red-fg}File pattern required{/}');
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
return;
}
// Set filter to show events for files matching pattern
this.activityStream.setFilter({ filePattern: pattern });
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {cyan-fg}Showing operations on files matching: ${pattern}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 2000);
}
/**
* Parse timestamp string (e.g., "14:30", "14:30:45", ISO date) to timestamp
*/
private parseTimestamp(timestamp: string): number | null {
// Try HH:MM format (assume today)
const timeMatch = timestamp.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (timeMatch) {
const now = new Date();
const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
const seconds = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
const targetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, seconds);
return targetDate.getTime();
}
// Try ISO date string
const isoDate = new Date(timestamp);
if (!isNaN(isoDate.getTime())) {
return isoDate.getTime();
}
return null;
}
/**
* Handle jump to timestamp command
*/
private handleJumpToTimestamp(timestamp: string): void {
const ts = this.parseTimestamp(timestamp);
if (ts === null) {
const originalContent = this.headerBox.getContent();
this.headerBox.setContent(` {red-fg}Invalid timestamp: ${timestamp}. Use format like 14:30 or 14:30:45{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 3000);
return;
}
// Navigate to the timestamp in activity stream
this.activityStream.scrollToTimestamp(ts);
const originalContent = this.headerBox.getContent();
const timeStr = new Date(ts).toLocaleTimeString();
this.headerBox.setContent(` {cyan-fg}Jumped to timestamp: ${timeStr}{/}`);
this.screen.render();
setTimeout(() => {
this.headerBox.setContent(originalContent);
this.updateHeader();
this.screen.render();
}, 1500);
}
/**
* Toggle heatmap view
*/

View file

@ -43,6 +43,12 @@ export interface ActivityFilter {
/** Filter by time range end (Unix timestamp in ms) */
until?: number;
/** Filter by bead ID */
beadId?: string;
/** Filter by file pattern (glob-style) */
filePattern?: string;
}
/**
@ -144,6 +150,18 @@ export class ActivityStream {
if (this.filter.until && event.ts > this.filter.until) {
return false;
}
if (this.filter.beadId && event.bead !== this.filter.beadId) {
return false;
}
if (this.filter.filePattern) {
const pattern = this.filter.filePattern.toLowerCase();
const path = event.path?.toLowerCase() || '';
const msg = event.msg.toLowerCase();
// Match against file path or message containing file references
if (!path.includes(pattern) && !msg.includes(pattern)) {
return false;
}
}
if (this.filter.search) {
const searchLower = this.filter.search.toLowerCase();
const matchesSearch =
@ -211,6 +229,57 @@ export class ActivityStream {
this.reRender();
}
/**
* Set time filter (show events since cutoff time)
*/
setTimeFilter(cutoffTime: number): void {
this.filter.since = cutoffTime;
this.reRender();
}
/**
* Scroll to a specific timestamp in the log
*/
scrollToTimestamp(timestamp: number): void {
// Find the event closest to the target timestamp
let closestIndex = -1;
let closestDiff = Infinity;
for (let i = 0; i < this.events.length; i++) {
const diff = Math.abs(this.events[i].ts - timestamp);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
}
}
if (closestIndex >= 0) {
// Re-render with context around the target
this.log.setContent('');
// Show 50 events before the target and 50 after
const start = Math.max(0, closestIndex - 50);
const end = Math.min(this.events.length, closestIndex + 51);
for (let i = start; i < end; i++) {
if (this.passesFilter(this.events[i])) {
const formatted = this.formatEvent(this.events[i]);
const isTarget = i === closestIndex;
if (isTarget) {
// Add indicator for the target event
this.log.log(`{yellow-fg}➜{/} ${formatted}`);
} else {
this.log.log(formatted);
}
}
}
// Scroll to make the target visible
this.log.setScroll((closestIndex - start) - 10);
this.log.screen.render();
}
}
/**
* Re-render all events with current filter
*/

View file

@ -46,19 +46,47 @@ const RECENT_COMMANDS_FILE = join(homedir(), '.fabric', 'recent-commands.json');
* Default command suggestions
*/
const DEFAULT_SUGGESTIONS: CommandSuggestion[] = [
// View commands
{ label: 'Show file heatmap', category: 'View', action: 'heatmap' },
{ label: 'Show dependency DAG', category: 'View', action: 'dag' },
{ label: 'Show session replay', category: 'View', action: 'replay' },
{ label: 'Show error groups', category: 'View', action: 'errors' },
{ label: 'Show session digest', category: 'View', action: 'digest' },
{ label: 'Show collision alerts', category: 'View', action: 'collisions' },
{ label: 'Show git integration', category: 'View', action: 'git' },
{ label: 'Show semantic narrative', category: 'View', action: 'narrative' },
{ label: 'Show worker analytics', category: 'View', action: 'analytics' },
{ label: 'Show budget dashboard', category: 'View', action: 'budget' },
{ label: 'Show conversation transcript', category: 'View', action: 'transcript' },
{ label: 'Show cross references', category: 'View', action: 'xref' },
// Filter commands
{ label: 'Filter by worker', category: 'Filter', action: 'filter:worker:' },
{ label: 'Filter by level', category: 'Filter', action: 'filter:level:' },
{ label: 'Filter by bead', category: 'Filter', action: 'filter:bead:' },
{ label: 'Filter by time (last:Nm/Nh)', category: 'Filter', action: 'filter:last:' },
{ label: 'Clear filters', category: 'Action', action: 'clear' },
// Navigation commands
{ label: 'Jump to worker', category: 'Navigation', action: 'worker:' },
{ label: 'Show bead events', category: 'Navigation', action: 'bead:' },
{ label: 'Show file operations', category: 'Navigation', action: 'file:' },
{ label: 'Jump to timestamp', category: 'Navigation', action: 'goto:' },
{ label: 'Help', category: 'Navigation', action: 'help' },
{ label: 'Quit', category: 'Navigation', action: 'quit' },
// Action commands
{ label: 'Toggle pause', category: 'Action', action: 'pause' },
{ label: 'Refresh', category: 'Action', action: 'refresh' },
// Theme commands
{ label: 'Toggle theme', category: 'Theme', action: 'theme:toggle' },
{ label: 'Dark theme', category: 'Theme', action: 'theme:dark' },
{ label: 'Light theme', category: 'Theme', action: 'theme:light' },
// Focus preset commands
{ label: 'Save focus preset', category: 'Focus Preset', action: 'preset:save' },
{ label: 'List focus presets', category: 'Focus Preset', action: 'preset:list' },
{ label: 'Help', category: 'Navigation', action: 'help' },
{ label: 'Quit', category: 'Navigation', action: 'quit' },
// Export commands
{ label: 'Export current view', category: 'Export', action: 'export' },
{ label: 'Export to file', category: 'Export', action: 'export:file' },
{ label: 'Export shareable link', category: 'Export', action: 'export:link' },
{ label: 'Import replay', category: 'Export', action: 'export:import' },
];
/**