From 6e3049dc1a1e0fa520dbd15facd92096b014f60e Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 27 Apr 2026 02:25:10 -0400 Subject: [PATCH] feat(tui): add missing command palette commands Add missing commands to TUI command palette as listed in docs/plan.md: - worker: - Jump to worker detail view - bead: - Show all events for a bead (cross-reference view) - file: - Show all operations on matching files - filter:last: - Filter to last N minutes (e.g., 5m, 1h) - goto: - 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 --- src/tui/app.ts | 393 ++++++++++++++++++++++++++- src/tui/components/ActivityStream.ts | 69 +++++ src/tui/components/CommandPalette.ts | 32 ++- 3 files changed, 485 insertions(+), 9 deletions(-) diff --git a/src/tui/app.ts b/src/tui/app.ts index e10681f..50dbd9b 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -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; + 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 " 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 */ diff --git a/src/tui/components/ActivityStream.ts b/src/tui/components/ActivityStream.ts index 22f3e2a..8adcf50 100644 --- a/src/tui/components/ActivityStream.ts +++ b/src/tui/components/ActivityStream.ts @@ -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 */ diff --git a/src/tui/components/CommandPalette.ts b/src/tui/components/CommandPalette.ts index 1550c31..4ee1f95 100644 --- a/src/tui/components/CommandPalette.ts +++ b/src/tui/components/CommandPalette.ts @@ -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' }, ]; /**