Implemented Focus Mode functionality with the following features: - Added keybindings: p (pin/unpin worker), P (pin/unpin bead), F (toggle focus) - Visual indicators (📌) for pinned workers and beads - Dimmed display for non-pinned items when Focus Mode is enabled - Updated footer to show Focus Mode status and pinned items - Updated help overlay with Focus Mode documentation - Added setFocusMode methods to WorkerGrid and ActivityStream components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
292 lines
6.7 KiB
TypeScript
292 lines
6.7 KiB
TypeScript
/**
|
|
* ActivityStream Component
|
|
*
|
|
* Displays scrolling log output with filtering capabilities.
|
|
*/
|
|
|
|
import * as blessed from 'blessed';
|
|
import { LogEvent } from '../../types.js';
|
|
import { colors, getLevelColor } from '../utils/colors.js';
|
|
|
|
export interface ActivityStreamOptions {
|
|
/** Parent screen */
|
|
parent: blessed.Widgets.Screen;
|
|
|
|
/** Position from top */
|
|
top: number | string;
|
|
|
|
/** Position from right */
|
|
right: number | string;
|
|
|
|
/** Width of the panel */
|
|
width: number | string;
|
|
|
|
/** Position from bottom */
|
|
bottom: number | string;
|
|
|
|
/** Maximum lines to keep in buffer */
|
|
maxLines?: number;
|
|
}
|
|
|
|
export interface ActivityFilter {
|
|
/** Filter by worker ID */
|
|
workerId?: string;
|
|
|
|
/** Filter by log level */
|
|
level?: string;
|
|
|
|
/** Filter by search term */
|
|
search?: string;
|
|
|
|
/** Filter by time range start (Unix timestamp in ms) */
|
|
since?: number;
|
|
|
|
/** Filter by time range end (Unix timestamp in ms) */
|
|
until?: number;
|
|
}
|
|
|
|
/**
|
|
* ActivityStream displays real-time log events
|
|
*/
|
|
export class ActivityStream {
|
|
private log: blessed.Widgets.Log;
|
|
private events: LogEvent[] = [];
|
|
private filter: ActivityFilter = {};
|
|
private maxLines: number;
|
|
private isPaused = false;
|
|
private focusModeEnabled = false;
|
|
private pinnedBeadId?: string;
|
|
private pinnedWorkerId?: string;
|
|
|
|
constructor(options: ActivityStreamOptions) {
|
|
this.maxLines = options.maxLines || 500;
|
|
|
|
this.log = blessed.log({
|
|
parent: options.parent,
|
|
top: options.top,
|
|
right: options.right,
|
|
width: options.width,
|
|
bottom: options.bottom,
|
|
label: ' Activity Stream ',
|
|
border: { type: 'line' },
|
|
style: {
|
|
border: { fg: colors.border },
|
|
label: { fg: colors.header },
|
|
},
|
|
scrollable: true,
|
|
alwaysScroll: true,
|
|
keys: true,
|
|
vi: true,
|
|
mouse: true,
|
|
});
|
|
|
|
this.bindKeys();
|
|
}
|
|
|
|
/**
|
|
* Bind component-specific keys
|
|
*/
|
|
private bindKeys(): void {
|
|
this.log.key(['p'], () => {
|
|
this.togglePause();
|
|
});
|
|
|
|
this.log.key(['C-c'], () => {
|
|
this.clear();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 = `[${event.tool}] ${msg}`;
|
|
}
|
|
|
|
// Check if this event is pinned
|
|
const isBeadPinned = this.pinnedBeadId && event.bead === this.pinnedBeadId;
|
|
const isWorkerPinned = this.pinnedWorkerId && event.worker === this.pinnedWorkerId;
|
|
const isPinned = isBeadPinned || isWorkerPinned;
|
|
const pinIndicator = isPinned ? '{yellow-fg}📌{/}' : '';
|
|
|
|
if (event.bead) {
|
|
msg = `{blue-fg}${event.bead}{/} ${pinIndicator}${msg}`;
|
|
}
|
|
|
|
// Dim non-pinned events when in focus mode
|
|
const shouldDim = this.focusModeEnabled && (this.pinnedBeadId || this.pinnedWorkerId) && !isPinned;
|
|
const dimPrefix = shouldDim ? '{gray-fg}' : '';
|
|
const dimSuffix = shouldDim ? '{/}' : '';
|
|
|
|
return `${dimPrefix}{gray-fg}${time}{/} {bold}${workerShort}{/} {${levelColor}-fg}${event.level.toUpperCase()}{/} ${msg}${dimSuffix}`;
|
|
}
|
|
|
|
/**
|
|
* Check if event passes current filter
|
|
*/
|
|
private passesFilter(event: LogEvent): boolean {
|
|
if (this.filter.workerId && event.worker !== this.filter.workerId) {
|
|
return false;
|
|
}
|
|
if (this.filter.level && event.level !== this.filter.level) {
|
|
return false;
|
|
}
|
|
if (this.filter.since && event.ts < this.filter.since) {
|
|
return false;
|
|
}
|
|
if (this.filter.until && event.ts > this.filter.until) {
|
|
return false;
|
|
}
|
|
if (this.filter.search) {
|
|
const searchLower = this.filter.search.toLowerCase();
|
|
const matchesSearch =
|
|
event.msg.toLowerCase().includes(searchLower) ||
|
|
event.worker.toLowerCase().includes(searchLower) ||
|
|
(event.tool?.toLowerCase().includes(searchLower) ?? false) ||
|
|
(event.bead?.toLowerCase().includes(searchLower) ?? false);
|
|
if (!matchesSearch) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Add event to the stream
|
|
*/
|
|
addEvent(event: LogEvent): void {
|
|
this.events.push(event);
|
|
|
|
// Trim old events
|
|
if (this.events.length > this.maxLines) {
|
|
this.events = this.events.slice(-this.maxLines);
|
|
}
|
|
|
|
// Only display if not paused and passes filter
|
|
if (!this.isPaused && this.passesFilter(event)) {
|
|
const formatted = this.formatEvent(event);
|
|
this.log.log(formatted);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add multiple events
|
|
*/
|
|
addEvents(events: LogEvent[]): void {
|
|
for (const event of events) {
|
|
this.addEvent(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle pause state
|
|
*/
|
|
togglePause(): void {
|
|
this.isPaused = !this.isPaused;
|
|
const label = this.isPaused ? ' Activity Stream [PAUSED] ' : ' Activity Stream ';
|
|
this.log.setLabel(label);
|
|
this.log.screen.render();
|
|
}
|
|
|
|
/**
|
|
* Set filter and re-render
|
|
*/
|
|
setFilter(filter: ActivityFilter): void {
|
|
this.filter = filter;
|
|
this.reRender();
|
|
}
|
|
|
|
/**
|
|
* Clear filter
|
|
*/
|
|
clearFilter(): void {
|
|
this.filter = {};
|
|
this.reRender();
|
|
}
|
|
|
|
/**
|
|
* Re-render all events with current filter
|
|
*/
|
|
private reRender(): void {
|
|
// Clear the log
|
|
this.log.setContent('');
|
|
|
|
// Re-add filtered events
|
|
const filtered = this.events.filter(e => this.passesFilter(e));
|
|
for (const event of filtered.slice(-100)) { // Show last 100 matching
|
|
const formatted = this.formatEvent(event);
|
|
this.log.log(formatted);
|
|
}
|
|
|
|
this.log.screen.render();
|
|
}
|
|
|
|
/**
|
|
* Clear all events
|
|
*/
|
|
clear(): void {
|
|
this.events = [];
|
|
this.log.setContent('');
|
|
this.log.screen.render();
|
|
}
|
|
|
|
/**
|
|
* Focus this component
|
|
*/
|
|
focus(): void {
|
|
this.log.focus();
|
|
}
|
|
|
|
/**
|
|
* Get the underlying log element
|
|
*/
|
|
getElement(): blessed.Widgets.Log {
|
|
return this.log;
|
|
}
|
|
|
|
/**
|
|
* Get pause state
|
|
*/
|
|
getIsPaused(): boolean {
|
|
return this.isPaused;
|
|
}
|
|
|
|
/**
|
|
* Get current filter
|
|
*/
|
|
getFilter(): ActivityFilter {
|
|
return { ...this.filter };
|
|
}
|
|
|
|
/**
|
|
* Get current events count
|
|
*/
|
|
getEventsCount(): number {
|
|
return this.events.length;
|
|
}
|
|
|
|
/**
|
|
* Get filtered events count
|
|
*/
|
|
getFilteredEventsCount(): number {
|
|
return this.events.filter(e => this.passesFilter(e)).length;
|
|
}
|
|
|
|
/**
|
|
* Set focus mode state
|
|
*/
|
|
setFocusMode(enabled: boolean, pinnedBeadId?: string, pinnedWorkerId?: string): void {
|
|
this.focusModeEnabled = enabled;
|
|
this.pinnedBeadId = pinnedBeadId;
|
|
this.pinnedWorkerId = pinnedWorkerId;
|
|
this.reRender();
|
|
}
|
|
}
|
|
|
|
export default ActivityStream;
|