FABRIC/src/tui/components/SessionReplay.ts
jeda e1d269ef01 feat(bd-3k9): P4-001: Session Replay - Add type fixes and complete implementation
- Add SessionReplay component with full playback controls
- Add replay command to CLI with filter support
- Fix color type references in SessionReplay
- Add EventFilter type import to CLI
- Add File Heatmap types for future feature

Co-Authored-By: Claude Worker <noreply@anthropic.com>
2026-03-03 11:58:06 +00:00

574 lines
14 KiB
TypeScript

/**
* SessionReplay Component
*
* Provides session replay functionality - ability to replay worker activity
* history chronologically with playback controls.
*/
import * as blessed from 'blessed';
import * as fs from 'fs';
import { EventEmitter } from 'events';
import { LogEvent, ReplaySpeed, ReplayState, EventFilter } from '../../types.js';
import { parseLogLine } from '../../parser.js';
import { colors, getLevelColor } from '../utils/colors.js';
export interface SessionReplayOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position from top */
top: number | string;
/** Position from left */
left: number | string;
/** Width of the panel */
width: number | string;
/** Height of the panel */
height: number | string;
/** Callback when event is emitted during playback */
onEvent?: (event: LogEvent, index: number, total: number) => void;
/** Callback when state changes */
onStateChange?: (state: ReplayState) => void;
}
export interface ReplaySessionData {
sourcePath: string;
events: LogEvent[];
startTime: number;
endTime: number;
}
/**
* SessionReplay handles loading and playing back historical log events
*/
export class SessionReplay extends EventEmitter {
private container: blessed.Widgets.BoxElement;
private timelineBox: blessed.Widgets.BoxElement;
private logBox: blessed.Widgets.Log;
private controlsBox: blessed.Widgets.BoxElement;
private parent: blessed.Widgets.Screen;
private events: LogEvent[] = [];
private filteredEvents: LogEvent[] = [];
private currentIndex: number = 0;
private state: ReplayState = 'idle';
private speed: ReplaySpeed = 1;
private filter?: EventFilter;
private playbackTimer?: NodeJS.Timeout;
private sourcePath: string = '';
private onEventCallback?: (event: LogEvent, index: number, total: number) => void;
private onStateChangeCallback?: (state: ReplayState) => void;
constructor(options: SessionReplayOptions) {
super();
this.parent = options.parent;
this.onEventCallback = options.onEvent;
this.onStateChangeCallback = options.onStateChange;
// Main container
this.container = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Session Replay ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
});
// Timeline bar at top
this.timelineBox = blessed.box({
parent: this.container,
top: 0,
left: 0,
right: 0,
height: 1,
content: this.formatTimeline(),
style: {
fg: colors.info,
bg: colors.bgPanel,
},
});
// Log display area
this.logBox = blessed.log({
parent: this.container,
top: 1,
left: 0,
right: 0,
bottom: 2,
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
style: {
fg: colors.info,
},
});
// Controls bar at bottom
this.controlsBox = blessed.box({
parent: this.container,
bottom: 0,
left: 0,
right: 0,
height: 2,
content: this.formatControls(),
style: {
fg: colors.muted,
},
});
this.bindKeys();
}
/**
* Bind keyboard shortcuts
*/
private bindKeys(): void {
this.container.key(['space'], () => this.toggle());
this.container.key(['p'], () => this.toggle());
this.container.key(['right'], () => this.stepForward());
this.container.key(['left'], () => this.stepBackward());
this.container.key(['n'], () => this.stepForward());
this.container.key(['b'], () => this.stepBackward());
this.container.key(['up'], () => this.increaseSpeed());
this.container.key(['down'], () => this.decreaseSpeed());
this.container.key(['home'], () => this.seekTo(0));
this.container.key(['end'], () => this.seekTo(this.filteredEvents.length - 1));
this.container.key(['r'], () => this.reset());
this.container.key(['1'], () => this.setSpeed(0.5));
this.container.key(['2'], () => this.setSpeed(1));
this.container.key(['3'], () => this.setSpeed(2));
this.container.key(['4'], () => this.setSpeed(5));
this.container.key(['5'], () => this.setSpeed(10));
}
/**
* Load events from a log file
*/
loadFile(filePath: string, filter?: EventFilter): Promise<number> {
return new Promise((resolve, reject) => {
const expandedPath = filePath.startsWith('~')
? filePath.replace('~', process.env.HOME || '')
: filePath;
if (!fs.existsSync(expandedPath)) {
reject(new Error(`Log file not found: ${expandedPath}`));
return;
}
this.sourcePath = expandedPath;
this.filter = filter;
const content = fs.readFileSync(expandedPath, 'utf-8');
const lines = content.split('\n');
this.events = [];
for (const line of lines) {
if (line.trim()) {
const event = parseLogLine(line);
if (event) {
this.events.push(event);
}
}
}
// Sort by timestamp
this.events.sort((a, b) => a.ts - b.ts);
// Apply filter
this.applyFilter();
// Reset state
this.currentIndex = 0;
this.state = 'idle';
this.updateDisplay();
this.emit('loaded', this.events.length);
resolve(this.events.length);
});
}
/**
* Load events from array
*/
loadEvents(events: LogEvent[], filter?: EventFilter): void {
this.events = [...events].sort((a, b) => a.ts - b.ts);
this.filter = filter;
this.applyFilter();
this.currentIndex = 0;
this.state = 'idle';
this.updateDisplay();
this.emit('loaded', this.events.length);
}
/**
* Apply current filter to events
*/
private applyFilter(): void {
if (!this.filter) {
this.filteredEvents = [...this.events];
return;
}
this.filteredEvents = this.events.filter(event => {
if (this.filter!.worker && event.worker !== this.filter!.worker) return false;
if (this.filter!.level && event.level !== this.filter!.level) return false;
if (this.filter!.bead && event.bead !== this.filter!.bead) return false;
if (this.filter!.path && event.path !== this.filter!.path) return false;
if (this.filter!.since && event.ts < this.filter!.since) return false;
if (this.filter!.until && event.ts > this.filter!.until) return false;
return true;
});
}
/**
* Set filter and reapply
*/
setFilter(filter?: EventFilter): void {
this.filter = filter;
this.applyFilter();
this.currentIndex = Math.min(this.currentIndex, Math.max(0, this.filteredEvents.length - 1));
this.updateDisplay();
}
/**
* Start or resume playback
*/
play(): void {
if (this.state === 'ended' || this.filteredEvents.length === 0) return;
this.state = 'playing';
this.onStateChangeCallback?.(this.state);
this.updateDisplay();
this.scheduleNextEvent();
}
/**
* Pause playback
*/
pause(): void {
if (this.state !== 'playing') return;
this.state = 'paused';
this.onStateChangeCallback?.(this.state);
this.clearTimer();
this.updateDisplay();
}
/**
* Toggle play/pause
*/
toggle(): void {
if (this.state === 'playing') {
this.pause();
} else {
this.play();
}
}
/**
* Step forward one event
*/
stepForward(): void {
if (this.currentIndex >= this.filteredEvents.length - 1) return;
this.pause();
this.currentIndex++;
this.displayCurrentEvent();
this.updateDisplay();
}
/**
* Step backward one event
*/
stepBackward(): void {
if (this.currentIndex <= 0) return;
this.pause();
this.currentIndex--;
this.displayCurrentEvent();
this.updateDisplay();
}
/**
* Jump to specific index
*/
seekTo(index: number): void {
const safeIndex = Math.max(0, Math.min(index, this.filteredEvents.length - 1));
if (safeIndex === this.currentIndex) return;
this.pause();
this.currentIndex = safeIndex;
this.displayCurrentEvent();
this.updateDisplay();
}
/**
* Seek to percentage (0-100)
*/
seekToPercent(percent: number): void {
const index = Math.floor((percent / 100) * (this.filteredEvents.length - 1));
this.seekTo(index);
}
/**
* Set playback speed
*/
setSpeed(speed: ReplaySpeed): void {
this.speed = speed;
this.updateDisplay();
// Reschedule if playing
if (this.state === 'playing') {
this.clearTimer();
this.scheduleNextEvent();
}
}
/**
* Increase speed
*/
increaseSpeed(): void {
const speeds: ReplaySpeed[] = [0.5, 1, 2, 5, 10];
const currentIdx = speeds.indexOf(this.speed);
if (currentIdx < speeds.length - 1) {
this.setSpeed(speeds[currentIdx + 1]);
}
}
/**
* Decrease speed
*/
decreaseSpeed(): void {
const speeds: ReplaySpeed[] = [0.5, 1, 2, 5, 10];
const currentIdx = speeds.indexOf(this.speed);
if (currentIdx > 0) {
this.setSpeed(speeds[currentIdx - 1]);
}
}
/**
* Reset replay to beginning
*/
reset(): void {
this.pause();
this.currentIndex = 0;
this.state = 'idle';
this.logBox.setContent('');
this.updateDisplay();
this.emit('reset');
}
/**
* Get current state
*/
getState(): ReplayState {
return this.state;
}
/**
* Get current speed
*/
getSpeed(): ReplaySpeed {
return this.speed;
}
/**
* Get progress info
*/
getProgress(): { current: number; total: number; percent: number } {
const total = this.filteredEvents.length;
const current = this.currentIndex;
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
return { current, total, percent };
}
/**
* Get session time range
*/
getTimeRange(): { start: number; end: number } | null {
if (this.filteredEvents.length === 0) return null;
return {
start: this.filteredEvents[0].ts,
end: this.filteredEvents[this.filteredEvents.length - 1].ts,
};
}
/**
* Schedule next event playback
*/
private scheduleNextEvent(): void {
if (this.state !== 'playing') return;
if (this.currentIndex >= this.filteredEvents.length) {
this.state = 'ended';
this.onStateChangeCallback?.(this.state);
this.updateDisplay();
this.emit('ended');
return;
}
// Calculate delay based on time difference and speed
let delay = 100; // Default 100ms between events
if (this.currentIndex > 0 && this.currentIndex < this.filteredEvents.length) {
const prevEvent = this.filteredEvents[this.currentIndex - 1];
const currEvent = this.filteredEvents[this.currentIndex];
const timeDiff = currEvent.ts - prevEvent.ts;
delay = Math.max(10, Math.min(5000, timeDiff / this.speed));
}
this.playbackTimer = setTimeout(() => {
this.displayCurrentEvent();
this.currentIndex++;
this.onEventCallback?.(
this.filteredEvents[this.currentIndex - 1],
this.currentIndex,
this.filteredEvents.length
);
this.updateDisplay();
this.scheduleNextEvent();
}, delay);
}
/**
* Clear playback timer
*/
private clearTimer(): void {
if (this.playbackTimer) {
clearTimeout(this.playbackTimer);
this.playbackTimer = undefined;
}
}
/**
* Display current event
*/
private displayCurrentEvent(): void {
if (this.currentIndex >= this.filteredEvents.length) return;
const event = this.filteredEvents[this.currentIndex];
const formatted = this.formatEvent(event);
this.logBox.log(formatted);
this.emit('event', event, this.currentIndex, this.filteredEvents.length);
}
/**
* Format event for display
*/
private formatEvent(event: LogEvent): string {
const time = new Date(event.ts).toLocaleTimeString();
const levelColor = getLevelColor(event.level as 'debug' | 'info' | 'warn' | 'error');
const workerShort = event.worker.slice(0, 8);
let msg = event.msg;
if (event.tool) {
msg = `[{cyan-fg}${event.tool}{/}] ${msg}`;
}
if (event.bead) {
msg = `{blue-fg}${event.bead}{/} ${msg}`;
}
return `{gray-fg}${time}{/} {bold}${workerShort}{/} {${levelColor}-fg}${event.level.toUpperCase()}{/} ${msg}`;
}
/**
* Format timeline display
*/
private formatTimeline(): string {
const { current, total, percent } = this.getProgress();
const timeRange = this.getTimeRange();
// Create progress bar
const barWidth = 30;
const filled = Math.round((percent / 100) * barWidth);
const empty = barWidth - filled;
const progressBar = '█'.repeat(filled) + '░'.repeat(empty);
let timeInfo = '';
if (timeRange) {
const startTime = new Date(timeRange.start).toLocaleTimeString();
const endTime = new Date(timeRange.end).toLocaleTimeString();
timeInfo = `${startTime} - ${endTime}`;
}
const stateIcon = this.getStateIcon();
return ` ${stateIcon} [${progressBar}] ${percent}% (${current}/${total}) ${timeInfo}`;
}
/**
* Get icon for current state
*/
private getStateIcon(): string {
switch (this.state) {
case 'playing': return '▶';
case 'paused': return '⏸';
case 'ended': return '⏹';
default: return '⏵';
}
}
/**
* Format controls display
*/
private formatControls(): string {
const speedDisplay = `${this.speed}x`;
return ` Speed: ${speedDisplay} | [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [1-5] 0.5x-10x | [Home/End] Jump | [r] Reset`;
}
/**
* Update display elements
*/
private updateDisplay(): void {
this.timelineBox.setContent(this.formatTimeline());
this.controlsBox.setContent(this.formatControls());
this.container.setLabel(` Session Replay ${this.sourcePath ? `- ${this.sourcePath.split('/').pop()} ` : ' '}`);
this.parent.render();
}
/**
* Focus this component
*/
focus(): void {
this.container.focus();
}
/**
* Show the component
*/
show(): void {
this.container.show();
this.parent.render();
}
/**
* Hide the component
*/
hide(): void {
this.container.hide();
this.parent.render();
}
/**
* Clean up resources
*/
destroy(): void {
this.clearTimer();
this.container.destroy();
}
}
export default SessionReplay;