Files
opencode-filter/src/visual/feedback-manager.ts
T
2026-04-10 21:57:22 -07:00

353 lines
8.5 KiB
TypeScript

/**
* OpenCode Filter - Visual Feedback Manager
*
* Central coordination for visual feedback in the TUI.
* Manages state, events, and statistics for the secret filter.
*
* PRIVACY: Never stores or displays actual secret values.
*/
import type { AuditEntry, AuditAction, SecretCategory } from '../types.js';
export type { AuditEntry };
/**
* Statistics for the filter state panel
*/
export interface FilterStatistics {
/** Total secrets filtered across all sessions */
totalSecretsFiltered: number;
/** Secrets filtered in current session */
sessionSecretsFiltered: number;
/** Number of active sessions with filtered secrets */
activeSessions: number;
/** Breakdown by category (never contains actual values) */
byCategory: Record<SecretCategory, number>;
/** Last filter activity timestamp */
lastActivity: string | null;
/** Whether filtering is currently enabled */
isEnabled: boolean;
/** Total operations (filter/restore) performed */
totalOperations: number;
}
/**
* Event data for secrets detected
*/
export interface SecretsDetectedEvent {
/** Number of secrets detected */
count: number;
/** Categories of detected secrets */
categories: string[];
/** Session ID where detection occurred */
sessionId?: string;
/** Timestamp of detection */
timestamp: string;
}
/**
* Event data for filter state change
*/
export interface FilterStateChangeEvent {
/** Whether filter is now enabled */
enabled: boolean;
/** Timestamp of change */
timestamp: string;
/** Reason for change (if applicable) */
reason?: string;
}
/**
* Callback function types for event subscriptions
*/
export type SecretsDetectedCallback = (event: SecretsDetectedEvent) => void;
export type StateChangeCallback = (event: FilterStateChangeEvent) => void;
export type AuditEntryCallback = (entry: AuditEntry) => void;
/**
* Feedback manager for coordinating visual feedback
* across the TUI plugin components
*/
export class FeedbackManager {
private stats: FilterStatistics;
private auditLog: AuditEntry[] = [];
private maxAuditEntries: number = 100;
// Event subscribers
private secretsDetectedCallbacks: SecretsDetectedCallback[] = [];
private stateChangeCallbacks: StateChangeCallback[] = [];
private auditEntryCallbacks: AuditEntryCallback[] = [];
constructor() {
this.stats = {
totalSecretsFiltered: 0,
sessionSecretsFiltered: 0,
activeSessions: 0,
byCategory: {
api_key: 0,
password: 0,
token: 0,
private_key: 0,
credential: 0,
certificate: 0,
connection_string: 0,
environment_variable: 0,
personal_info: 0,
other: 0,
},
lastActivity: null,
isEnabled: true,
totalOperations: 0,
};
}
/**
* Record a secrets detection event
*/
recordSecretsDetected(event: SecretsDetectedEvent): void {
this.stats.totalSecretsFiltered += event.count;
this.stats.sessionSecretsFiltered += event.count;
this.stats.totalOperations++;
this.stats.lastActivity = event.timestamp;
for (const category of event.categories) {
if (category in this.stats.byCategory) {
this.stats.byCategory[category as SecretCategory]++;
}
}
for (const callback of this.secretsDetectedCallbacks) {
try {
callback(event);
} catch (error) {
console.error('Error in secrets detected callback:', error);
}
}
}
/**
* Record a filter state change
*/
setFilterEnabled(enabled: boolean, reason?: string): void {
const previousState = this.stats.isEnabled;
this.stats.isEnabled = enabled;
if (previousState !== enabled) {
const event: FilterStateChangeEvent = {
enabled,
timestamp: new Date().toISOString(),
reason,
};
for (const callback of this.stateChangeCallbacks) {
try {
callback(event);
} catch (error) {
console.error('Error in state change callback:', error);
}
}
}
}
/**
* Get current filter statistics
*/
getStats(): FilterStatistics {
return { ...this.stats };
}
/**
* Add an audit entry to the log
* PRIVACY: Only placeholders and metadata are stored, never actual secrets
*/
addAuditEntry(entry: AuditEntry): void {
this.auditLog.push(entry);
if (this.auditLog.length > this.maxAuditEntries) {
this.auditLog = this.auditLog.slice(-this.maxAuditEntries);
}
for (const callback of this.auditEntryCallbacks) {
try {
callback(entry);
} catch (error) {
console.error('Error in audit entry callback:', error);
}
}
if (entry.action === 'FILTERED') {
this.stats.totalOperations++;
this.stats.lastActivity = entry.timestamp;
}
}
/**
* Get recent audit entries
*/
getAuditEntries(options?: {
limit?: number;
action?: AuditAction;
category?: string;
}): AuditEntry[] {
let entries = [...this.auditLog];
if (options?.action) {
entries = entries.filter(e => e.action === options.action);
}
if (options?.category) {
entries = entries.filter(e => e.category === options.category);
}
const limit = options?.limit ?? 50;
return entries.slice(-limit);
}
/**
* Clear audit log
*/
clearAuditLog(): void {
this.auditLog = [];
}
/**
* Reset session-specific statistics
*/
resetSessionStats(): void {
this.stats.sessionSecretsFiltered = 0;
}
/**
* Subscribe to secrets detected events
* @returns Unsubscribe function
*/
onSecretsDetected(callback: SecretsDetectedCallback): () => void {
this.secretsDetectedCallbacks.push(callback);
return () => {
const index = this.secretsDetectedCallbacks.indexOf(callback);
if (index > -1) {
this.secretsDetectedCallbacks.splice(index, 1);
}
};
}
/**
* Subscribe to filter state change events
* @returns Unsubscribe function
*/
onStateChange(callback: StateChangeCallback): () => void {
this.stateChangeCallbacks.push(callback);
return () => {
const index = this.stateChangeCallbacks.indexOf(callback);
if (index > -1) {
this.stateChangeCallbacks.splice(index, 1);
}
};
}
/**
* Subscribe to audit entry events
* @returns Unsubscribe function
*/
onAuditEntry(callback: AuditEntryCallback): () => void {
this.auditEntryCallbacks.push(callback);
return () => {
const index = this.auditEntryCallbacks.indexOf(callback);
if (index > -1) {
this.auditEntryCallbacks.splice(index, 1);
}
};
}
/**
* Reset all statistics and audit log
*/
resetAll(): void {
this.stats = {
totalSecretsFiltered: 0,
sessionSecretsFiltered: 0,
activeSessions: 0,
byCategory: {
api_key: 0,
password: 0,
token: 0,
private_key: 0,
credential: 0,
certificate: 0,
connection_string: 0,
environment_variable: 0,
personal_info: 0,
other: 0,
},
lastActivity: null,
isEnabled: true,
totalOperations: 0,
};
this.auditLog = [];
}
}
/**
* Global feedback manager instance for sharing state across components
*/
let globalFeedbackManager: FeedbackManager | null = null;
/**
* Get or create the global feedback manager instance
*/
export function getFeedbackManager(): FeedbackManager {
if (!globalFeedbackManager) {
globalFeedbackManager = new FeedbackManager();
}
return globalFeedbackManager;
}
/**
* Reset the global feedback manager instance
*/
export function resetFeedbackManager(): void {
globalFeedbackManager = null;
}
/**
* Format statistics for display in the TUI
*/
export function formatStatsForDisplay(stats: FilterStatistics): string {
const lines = [
`Status: ${stats.isEnabled ? '✅ Enabled' : '❌ Disabled'}`,
`Total Filtered: ${stats.totalSecretsFiltered}`,
`This Session: ${stats.sessionSecretsFiltered}`,
`Operations: ${stats.totalOperations}`,
];
if (stats.lastActivity) {
const time = new Date(stats.lastActivity).toLocaleTimeString();
lines.push(`Last Activity: ${time}`);
}
return lines.join('\n');
}
/**
* Format an audit entry for display (privacy-safe)
*/
export function formatAuditEntryForDisplay(entry: AuditEntry): string {
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
const action = entry.action;
const category = entry.category;
const placeholder = entry.placeholder.slice(0, 25);
return `[${timestamp}] ${action} | ${category} | ${placeholder}`;
}