mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
/**
|
|
* Activity detection system for terminal output
|
|
*
|
|
* Provides generic activity tracking and app-specific status parsing
|
|
* for enhanced terminal title updates in dynamic mode.
|
|
*/
|
|
|
|
import { createLogger } from './logger.js';
|
|
|
|
const logger = createLogger('activity-detector');
|
|
|
|
// Debug flag - set to true to enable verbose logging
|
|
const CLAUDE_DEBUG = process.env.VIBETUNNEL_CLAUDE_DEBUG === 'true';
|
|
|
|
// Super debug logging wrapper
|
|
function superDebug(message: string, ...args: unknown[]): void {
|
|
if (CLAUDE_DEBUG) {
|
|
console.log(`[ActivityDetector:DEBUG] ${message}`, ...args);
|
|
}
|
|
}
|
|
|
|
// ANSI escape code removal regex
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes need control characters
|
|
const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
|
|
/**
|
|
* Activity status returned by app-specific parsers
|
|
*/
|
|
export interface ActivityStatus {
|
|
/** The output data with status lines filtered out */
|
|
filteredData: string;
|
|
/** Human-readable status text for display in title */
|
|
displayText: string;
|
|
/** Raw status data for potential future use */
|
|
raw?: {
|
|
indicator?: string;
|
|
action?: string;
|
|
duration?: number;
|
|
progress?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Current activity state for a terminal session
|
|
*/
|
|
export interface ActivityState {
|
|
/** Whether the terminal is currently active */
|
|
isActive: boolean;
|
|
/** Timestamp of last activity */
|
|
lastActivityTime: number;
|
|
/** App-specific status if detected */
|
|
specificStatus?: {
|
|
app: string;
|
|
status: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* App-specific detector interface
|
|
*/
|
|
export interface AppDetector {
|
|
/** Name of the app this detector handles */
|
|
name: string;
|
|
/** Check if this detector should be used for the given command */
|
|
detect: (command: string[]) => boolean;
|
|
/** Parse app-specific status from output data */
|
|
parseStatus: (data: string) => ActivityStatus | null;
|
|
}
|
|
|
|
// Pre-compiled regex for Claude status lines
|
|
// Format 1: ✻ Crafting… (205s · ↑ 6.0k tokens · <any text> to interrupt)
|
|
// Format 2: ✻ Measuring… (6s · 100 tokens · esc to interrupt)
|
|
// Format 3: ⏺ Calculating… (0s) - simpler format without tokens/interrupt
|
|
// Format 4: ✳ Measuring… (120s · ⚒ 671 tokens · esc to interrupt) - with hammer symbol
|
|
// Note: We match ANY non-whitespace character as the indicator since Claude uses many symbols
|
|
const CLAUDE_STATUS_REGEX =
|
|
/(\S)\s+(\w+)…\s*\((\d+)s(?:\s*·\s*(\S?)\s*([\d.]+)\s*k?\s*tokens\s*·\s*[^)]+to\s+interrupt)?\)/gi;
|
|
|
|
/**
|
|
* Parse Claude-specific status from output
|
|
*/
|
|
function parseClaudeStatus(data: string): ActivityStatus | null {
|
|
// Strip ANSI escape codes for cleaner matching
|
|
const cleanData = data.replace(ANSI_REGEX, '');
|
|
|
|
// Reset regex lastIndex since we're using global flag
|
|
CLAUDE_STATUS_REGEX.lastIndex = 0;
|
|
|
|
// Log if we see something that looks like a Claude status
|
|
if (cleanData.includes('interrupt') && cleanData.includes('tokens')) {
|
|
superDebug('Potential Claude status detected');
|
|
superDebug('Clean data sample:', cleanData.substring(0, 200).replace(/\n/g, '\\n'));
|
|
}
|
|
|
|
const match = CLAUDE_STATUS_REGEX.exec(cleanData);
|
|
if (!match) {
|
|
// Debug log to see what we're trying to match
|
|
if (cleanData.includes('interrupt') && cleanData.includes('tokens')) {
|
|
superDebug('Claude status line NOT matched');
|
|
superDebug('Looking for pattern like: ✻ Crafting… (123s · ↑ 6.0k tokens · ... to interrupt)');
|
|
superDebug('Clean data preview:', cleanData.substring(0, 150));
|
|
|
|
// Try to find the specific line that contains the status
|
|
const lines = cleanData.split('\n');
|
|
const statusLine = lines.find(
|
|
(line) => line.includes('interrupt') && line.includes('tokens')
|
|
);
|
|
if (statusLine) {
|
|
superDebug('Found status line:', statusLine);
|
|
superDebug('Line length:', statusLine.length);
|
|
// Log each character to debug special symbols
|
|
if (CLAUDE_DEBUG) {
|
|
const chars = Array.from(statusLine.substring(0, 50));
|
|
chars.forEach((char, idx) => {
|
|
console.log(
|
|
` [${idx}] '${char}' = U+${char.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const [fullMatch, indicator, action, duration, direction, tokens] = match;
|
|
|
|
// Handle both formats - with and without token information
|
|
const hasTokenInfo = direction !== undefined && tokens !== undefined;
|
|
|
|
superDebug(`Claude status MATCHED!`);
|
|
superDebug(
|
|
`Action: ${action}, Duration: ${duration}s, Direction: ${direction}, Tokens: ${tokens}`
|
|
);
|
|
superDebug(`Indicator: '${indicator}'`);
|
|
logger.debug(
|
|
`Claude status MATCHED! Action: ${action}, Duration: ${duration}s, Direction: ${direction}, Tokens: ${tokens}`
|
|
);
|
|
logger.debug(`Full match: "${fullMatch}"`);
|
|
|
|
// Filter out the status line from output (need to search in original data with ANSI codes)
|
|
// First try to remove the exact match from the clean data position
|
|
const matchIndex = cleanData.indexOf(fullMatch);
|
|
let filteredData = data;
|
|
if (matchIndex >= 0) {
|
|
// Find corresponding position in original data
|
|
let originalPos = 0;
|
|
let cleanPos = 0;
|
|
while (cleanPos < matchIndex && originalPos < data.length) {
|
|
if (data.startsWith('\x1b[', originalPos)) {
|
|
// Skip ANSI sequence
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes need control characters
|
|
const endMatch = /^\x1b\[[0-9;]*[a-zA-Z]/.exec(data.substring(originalPos));
|
|
if (endMatch) {
|
|
originalPos += endMatch[0].length;
|
|
} else {
|
|
originalPos++;
|
|
}
|
|
} else {
|
|
originalPos++;
|
|
cleanPos++;
|
|
}
|
|
}
|
|
// Now try to remove the status line from around this position
|
|
const before = data.substring(0, Math.max(0, originalPos - 10));
|
|
const after = data.substring(originalPos + fullMatch.length + 50);
|
|
const middle = data.substring(
|
|
Math.max(0, originalPos - 10),
|
|
originalPos + fullMatch.length + 50
|
|
);
|
|
// Look for the status pattern in the middle section
|
|
const statusPattern = new RegExp(`[^\n]*${indicator}[^\n]*to\\s+interrupt[^\n]*`, 'gi');
|
|
const cleanedMiddle = middle.replace(statusPattern, '');
|
|
filteredData = before + cleanedMiddle + after;
|
|
}
|
|
|
|
// Create compact display text for title bar
|
|
let displayText: string;
|
|
if (hasTokenInfo) {
|
|
// Format tokens - the input already has 'k' suffix in the regex pattern
|
|
// So "6.0" means 6.0k tokens, not 6.0 tokens
|
|
const formattedTokens = `${tokens}k`;
|
|
displayText = `${indicator} ${action} (${duration}s, ${direction}${formattedTokens})`;
|
|
} else {
|
|
// Simple format without token info
|
|
displayText = `${indicator} ${action} (${duration}s)`;
|
|
}
|
|
|
|
return {
|
|
filteredData,
|
|
displayText,
|
|
raw: {
|
|
indicator,
|
|
action,
|
|
duration: Number.parseInt(duration),
|
|
progress: hasTokenInfo ? `${direction}${tokens} tokens` : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Registry of app-specific detectors
|
|
const detectors: AppDetector[] = [
|
|
{
|
|
name: 'claude',
|
|
detect: (cmd) => {
|
|
// Check if any part of the command contains 'claude'
|
|
const cmdStr = cmd.join(' ').toLowerCase();
|
|
return cmdStr.includes('claude');
|
|
},
|
|
parseStatus: parseClaudeStatus,
|
|
},
|
|
// Future detectors can be added here:
|
|
// npm, git, docker, etc.
|
|
];
|
|
|
|
/**
|
|
* Activity detector for a terminal session
|
|
*
|
|
* Tracks general activity and provides app-specific status parsing
|
|
*/
|
|
export class ActivityDetector {
|
|
private lastActivityTime = Date.now();
|
|
private currentStatus: ActivityStatus | null = null;
|
|
private detector: AppDetector | null = null;
|
|
private lastStatusTime = 0; // Track when we last saw a status line
|
|
private readonly ACTIVITY_TIMEOUT = 5000; // 5 seconds
|
|
private readonly STATUS_TIMEOUT = 10000; // 10 seconds - clear status if not seen
|
|
private readonly MEANINGFUL_OUTPUT_THRESHOLD = 5; // characters
|
|
|
|
constructor(command: string[]) {
|
|
// Find matching detector for this command
|
|
this.detector = detectors.find((d) => d.detect(command)) || null;
|
|
|
|
if (this.detector) {
|
|
logger.log(
|
|
`ActivityDetector: Using ${this.detector.name} detector for command: ${command.join(' ')}`
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
`ActivityDetector: No specific detector found for command: ${command.join(' ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if output is just a prompt
|
|
*/
|
|
private isJustPrompt(data: string): boolean {
|
|
// Common prompt patterns that shouldn't count as activity
|
|
return /^[$>#%❯➜]\s*$/.test(data) || /^\[.*\][$>#]\s*$/.test(data);
|
|
}
|
|
|
|
/**
|
|
* Process terminal output and extract activity information
|
|
*/
|
|
processOutput(data: string): { filteredData: string; activity: ActivityState } {
|
|
// Don't count as activity if it's just a prompt or empty output
|
|
const trimmed = data.trim();
|
|
const isMeaningfulOutput =
|
|
trimmed.length > this.MEANINGFUL_OUTPUT_THRESHOLD && !this.isJustPrompt(trimmed);
|
|
|
|
if (isMeaningfulOutput) {
|
|
this.lastActivityTime = Date.now();
|
|
}
|
|
|
|
// Log when we process output with a detector
|
|
if (this.detector && data.length > 10) {
|
|
superDebug(`Processing output with ${this.detector.name} detector (${data.length} chars)`);
|
|
}
|
|
|
|
// Try app-specific detection first
|
|
if (this.detector) {
|
|
const status = this.detector.parseStatus(data);
|
|
if (status) {
|
|
this.currentStatus = status;
|
|
this.lastStatusTime = Date.now();
|
|
// Always update activity time for app-specific status
|
|
this.lastActivityTime = Date.now();
|
|
return {
|
|
filteredData: status.filteredData,
|
|
activity: {
|
|
isActive: true,
|
|
lastActivityTime: this.lastActivityTime,
|
|
specificStatus: {
|
|
app: this.detector.name,
|
|
status: status.displayText,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Generic activity detection
|
|
return {
|
|
filteredData: data,
|
|
activity: {
|
|
isActive: isMeaningfulOutput,
|
|
lastActivityTime: this.lastActivityTime,
|
|
specificStatus:
|
|
this.currentStatus && this.detector
|
|
? {
|
|
app: this.detector.name,
|
|
status: this.currentStatus.displayText,
|
|
}
|
|
: undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current activity state (for periodic updates)
|
|
*/
|
|
getActivityState(): ActivityState {
|
|
const now = Date.now();
|
|
const isActive = now - this.lastActivityTime < this.ACTIVITY_TIMEOUT;
|
|
|
|
// Clear status if we haven't seen it for a while
|
|
if (this.currentStatus && now - this.lastStatusTime > this.STATUS_TIMEOUT) {
|
|
logger.debug('Clearing stale status - not seen for', this.STATUS_TIMEOUT, 'ms');
|
|
this.currentStatus = null;
|
|
}
|
|
|
|
// If we have a specific status (like Claude running), always show it
|
|
// The activity indicator in the title will show if it's active or not
|
|
return {
|
|
isActive,
|
|
lastActivityTime: this.lastActivityTime,
|
|
specificStatus:
|
|
this.currentStatus && this.detector
|
|
? {
|
|
app: this.detector.name,
|
|
status: this.currentStatus.displayText,
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear current status (e.g., when session ends)
|
|
*/
|
|
clearStatus(): void {
|
|
this.currentStatus = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a new app detector
|
|
*
|
|
* @param detector The detector to register
|
|
*/
|
|
export function registerDetector(detector: AppDetector): void {
|
|
const existing = detectors.findIndex((d) => d.name === detector.name);
|
|
if (existing >= 0) {
|
|
detectors[existing] = detector;
|
|
logger.debug(`Updated ${detector.name} detector`);
|
|
} else {
|
|
detectors.push(detector);
|
|
logger.debug(`Registered ${detector.name} detector`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test function to help debug Claude status detection
|
|
* @param testData Sample data to test the regex against
|
|
*/
|
|
export function testClaudeStatusDetection(testData: string): void {
|
|
console.log('\n=== Testing Claude Status Detection ===');
|
|
console.log('Raw data length:', testData.length);
|
|
console.log('Raw data (first 300 chars):', testData.substring(0, 300).replace(/\n/g, '\\n'));
|
|
|
|
// Test with current implementation
|
|
const result = parseClaudeStatus(testData);
|
|
if (result) {
|
|
console.log('✅ Status detected:', result.displayText);
|
|
} else {
|
|
console.log('❌ No status detected');
|
|
|
|
// Try different variations
|
|
const cleanData = testData.replace(ANSI_REGEX, '');
|
|
console.log('\nClean data (no ANSI):', cleanData.substring(0, 300).replace(/\n/g, '\\n'));
|
|
|
|
// Test simpler patterns
|
|
const patterns = [
|
|
/tokens.*interrupt/gi,
|
|
/\d+s.*tokens/gi,
|
|
/[↑↓]\s*\d+.*tokens/gi,
|
|
/(\w+)….*\d+s/gi,
|
|
];
|
|
|
|
patterns.forEach((pattern, idx) => {
|
|
if (pattern.test(cleanData)) {
|
|
console.log(`✓ Pattern ${idx} matches:`, pattern.toString());
|
|
const match = pattern.exec(cleanData);
|
|
if (match) {
|
|
console.log(' Match:', match[0].substring(0, 100));
|
|
}
|
|
} else {
|
|
console.log(`✗ Pattern ${idx} no match:`, pattern.toString());
|
|
}
|
|
pattern.lastIndex = 0; // Reset
|
|
});
|
|
}
|
|
console.log('=== End Test ===\n');
|
|
}
|