FABRIC/src/tui/components/CollisionAlert.ts
jeda 5fab75708f feat(bd-xig): Implement worker collision detection
- Add BeadCollision, TaskCollision, CollisionAlert types
- Extend WorkerInfo to track activeBead and activeDirectories
- Implement bead collision detection ( detectBeadCollision, getBeadCollisions, getWorkerBeadCollisions)
- Implement task collision detection ( detectTaskCollision, getTaskCollisions
- Implement getWorkerTaskCollisions
- Generate collision alerts with suggestions
- Add getCollisionStats for statistics
- Add cleanupStaleCollisions for bead and task collisions
- Create CollisionAlert TUI component

- Add unit tests for collision detection

🚀 Generated with Claude Worker <noreply@anthropic.com>

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-03 13:50:02 +00:00

329 lines
8.2 KiB
TypeScript

/**
* CollisionAlert Component
*
* Displays collision alerts to users, warning about potential duplicate work
* or conflicting operations between workers.
*/
import * as blessed from 'blessed';
import { CollisionAlert as CollisionAlertData, FileCollision, BeadCollision, TaskCollision } from '../../types.js';
import { colors } from '../utils/colors.js';
export interface CollisionAlertOptions {
/** 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 alert is acknowledged */
onAcknowledge?: (alertId: string) => void;
}
/**
* CollisionAlert displays collision warnings and alerts
*/
export class CollisionAlert {
private box: blessed.Widgets.BoxElement;
private list: blessed.Widgets.ListElement;
private alerts: CollisionAlertData[] = [];
private selectedIndex = 0;
private onAcknowledge?: (alertId: string) => void;
constructor(options: CollisionAlertOptions) {
this.onAcknowledge = options.onAcknowledge;
this.box = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Collision Alerts ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.warning },
selected: { fg: colors.focus },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
});
// Create inner list for alerts
this.list = blessed.list({
parent: this.box,
top: 0,
left: 0,
right: 0,
bottom: 0,
keys: true,
vi: true,
mouse: true,
style: {
selected: { fg: colors.focus, bold: true },
item: { fg: colors.text },
},
});
this.bindKeys();
}
/**
* Bind component-specific keys
*/
private bindKeys(): void {
this.list.key(['up', 'k'], () => {
this.selectPrevious();
});
this.list.key(['down', 'j'], () => {
this.selectNext();
});
this.list.key(['enter', 'space'], () => {
this.acknowledgeSelected();
});
this.list.key(['a'], () => {
this.acknowledgeAll();
});
this.list.key(['escape'], () => {
this.hide();
});
}
/**
* Get severity icon and color
*/
private getSeverityStyle(severity: CollisionAlertData['severity']): { icon: string; color: string } {
switch (severity) {
case 'critical':
return { icon: '!!!', color: 'red' };
case 'error':
return { icon: ' !!', color: 'red' };
case 'warning':
return { icon: ' !', color: 'yellow' };
case 'info':
return { icon: ' i', color: 'blue' };
}
}
/**
* Get type icon
*/
private getTypeIcon(type: CollisionAlertData['type']): string {
switch (type) {
case 'file':
return 'F';
case 'bead':
return 'B';
case 'task':
return 'T';
}
}
/**
* Format alert for display
*/
private formatAlertLine(alert: CollisionAlertData, isSelected: boolean): string {
const severity = this.getSeverityStyle(alert.severity);
const typeIcon = this.getTypeIcon(alert.type);
const ackMarker = alert.acknowledged ? '{gray-fg}[ACK]{/} ' : '';
const workers = alert.workers.length > 2
? `${alert.workers.length} workers`
: alert.workers.join(', ').slice(0, 15);
const title = alert.title.slice(0, 40);
return `${ackMarker}{${severity.color}-fg}${severity.icon}{/} [${typeIcon}] ${title} {cyan-fg}${workers}{/}`;
}
/**
* Update alerts data
*/
updateAlerts(alerts: CollisionAlertData[]): void {
this.alerts = alerts;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, alerts.length - 1));
this.render();
}
/**
* Select next alert
*/
selectNext(): void {
if (this.alerts.length === 0) return;
this.selectedIndex = (this.selectedIndex + 1) % this.alerts.length;
this.render();
}
/**
* Select previous alert
*/
selectPrevious(): void {
if (this.alerts.length === 0) return;
this.selectedIndex = this.selectedIndex === 0
? this.alerts.length - 1
: this.selectedIndex - 1;
this.render();
}
/**
* Acknowledge selected alert
*/
acknowledgeSelected(): void {
if (this.alerts.length === 0) return;
const alert = this.alerts[this.selectedIndex];
if (!alert.acknowledged) {
alert.acknowledged = true;
this.onAcknowledge?.(alert.id);
this.render();
}
}
/**
* Acknowledge all alerts
*/
acknowledgeAll(): void {
for (const alert of this.alerts) {
if (!alert.acknowledged) {
alert.acknowledged = true;
this.onAcknowledge?.(alert.id);
}
}
this.render();
}
/**
* Get currently selected alert
*/
getSelected(): CollisionAlertData | undefined {
return this.alerts[this.selectedIndex];
}
/**
* Get unacknowledged alert count
*/
getUnacknowledgedCount(): number {
return this.alerts.filter(a => !a.acknowledged).length;
}
/**
* Show the panel
*/
show(): void {
this.box.show();
this.list.focus();
this.render();
}
/**
* Hide the panel
*/
hide(): void {
this.box.hide();
this.box.screen.render();
}
/**
* Check if visible
*/
isVisible(): boolean {
return this.box.visible;
}
/**
* Render the component
*/
render(): void {
const lines: string[] = [];
if (this.alerts.length === 0) {
lines.push('{green-fg}No active collisions detected{/}');
} else {
const unacked = this.getUnacknowledgedCount();
lines.push(`{bold}Alerts: ${this.alerts.length} (${unacked} unacknowledged){/}\n`);
// Group by severity
const critical = this.alerts.filter(a => a.severity === 'critical' || a.severity === 'error');
const warnings = this.alerts.filter(a => a.severity === 'warning');
const info = this.alerts.filter(a => a.severity === 'info');
if (critical.length > 0) {
lines.push(`\n{red-fg}CRITICAL/ERROR (${critical.length}):{/}`);
for (let i = 0; i < critical.length; i++) {
const alert = critical[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
if (warnings.length > 0) {
lines.push(`\n{yellow-fg}WARNINGS (${warnings.length}):{/}`);
for (let i = 0; i < warnings.length; i++) {
const alert = warnings[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
if (info.length > 0) {
lines.push(`\n{blue-fg}INFO (${info.length}):{/}`);
for (let i = 0; i < info.length; i++) {
const alert = info[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
// Show selected alert details at bottom
const selected = this.alerts[this.selectedIndex];
if (selected) {
lines.push(`\n{bold}──────────────────────────────────────────{/}`);
lines.push(`{bold}Selected Alert Details:{/}`);
lines.push(` Title: ${selected.title}`);
lines.push(` ${selected.description}`);
lines.push(` Workers: ${selected.workers.join(', ')}`);
if (selected.suggestion) {
lines.push(` {cyan-fg}Suggestion: ${selected.suggestion}{/}`);
}
lines.push(`\n {gray-fg}[Enter] Acknowledge [a] Acknowledge All [Esc] Close{/}`);
}
}
this.box.setContent(lines.join('\n'));
this.box.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.list.focus();
}
/**
* Get the underlying box element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
}
export default CollisionAlert;