#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributers. from fenrirscreenreader.core import debug class command(): def __init__(self): pass def initialize(self, environment): self.env = environment def shutdown(self): pass def getDescription(self): return 'Detects progress patterns for progress bar monitoring' def run(self): # Only run if progress monitoring is enabled try: if 'progressMonitoring' in self.env['commandBuffer'] and self.env['commandBuffer']['progressMonitoring']: # Only check new incoming text (newDelta), but filter out screen changes if self.env['screen']['newDelta'] and self.isRealProgressUpdate(): self.detectProgress(self.env['screen']['newDelta']) except Exception as e: # Silently ignore errors to avoid disrupting normal operation pass def isRealProgressUpdate(self): """Check if this is a real progress update vs screen change/window switch""" # If the screen/application changed, it's not a progress update if self.env['runtime']['screenManager'].isScreenChange(): return False # If there was a large cursor movement, it's likely navigation, not progress if self.env['runtime']['cursorManager'].isCursorVerticalMove(): xMove = abs(self.env['screen']['newCursor']['x'] - self.env['screen']['oldCursor']['x']) yMove = abs(self.env['screen']['newCursor']['y'] - self.env['screen']['oldCursor']['y']) # Large movements suggest navigation, not progress output if yMove > 2 or xMove > 20: return False # Check if delta is too large (screen change) vs small incremental updates deltaLength = len(self.env['screen']['newDelta']) if deltaLength > 200: # Allow longer progress lines like Claude Code's status return False # Check if current line looks like a prompt - progress unlikely during prompts if self.isCurrentLinePrompt(): return False return True def detectProgress(self, text): import re import time currentTime = time.time() # Debug: Print what we're checking self.env['runtime']['debug'].writeDebugOut("Progress detector checking: '" + text + "'", debug.debugLevel.INFO) # Note: Auto-disable on 100% completion removed to respect user settings # Pattern 1: Percentage (50%, 25.5%, etc.) percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) if percentMatch: percentage = float(percentMatch.group(1)) # Only trigger on realistic progress percentages (0-100%) if 0 <= percentage <= 100: self.env['runtime']['debug'].writeDebugOut("Found percentage: " + str(percentage), debug.debugLevel.INFO) if percentage != self.env['commandBuffer']['lastProgressValue']: self.env['runtime']['debug'].writeDebugOut("Playing tone for: " + str(percentage), debug.debugLevel.INFO) self.playProgressTone(percentage) self.env['commandBuffer']['lastProgressValue'] = percentage self.env['commandBuffer']['lastProgressTime'] = currentTime return # Pattern 1b: Time/token activity (not percentage-based, so use single beep) timeMatch = re.search(r'(\d+)s\s', text) tokenMatch = re.search(r'(\d+)\s+tokens', text) # Pattern 1c: dd command output (bytes copied with transfer rate) ddMatch = re.search(r'\d+\s+bytes.*copied.*\d+\s+s.*[kMGT]?B/s', text) # Pattern 1d: Curl-style transfer data (bytes, speed indicators) curlMatch = re.search(r'(\d+\s+\d+\s+\d+\s+\d+.*?(?:k|M|G)?.*?--:--:--|Speed)', text) if timeMatch or tokenMatch or ddMatch or curlMatch: # For non-percentage progress, use a single activity beep every 2 seconds if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: self.env['runtime']['debug'].writeDebugOut("Playing activity beep for transfer progress", debug.debugLevel.INFO) self.playActivityBeep() self.env['commandBuffer']['lastProgressTime'] = currentTime return # Pattern 2: Fraction (15/100, 3 of 10, etc.) fractionMatch = re.search(r'(\d+)\s*(?:of|/)\s*(\d+)', text) if fractionMatch: current = int(fractionMatch.group(1)) total = int(fractionMatch.group(2)) if total > 0: percentage = (current / total) * 100 if percentage != self.env['commandBuffer']['lastProgressValue']: self.playProgressTone(percentage) self.env['commandBuffer']['lastProgressValue'] = percentage self.env['commandBuffer']['lastProgressTime'] = currentTime return # Pattern 3: Progress bars ([#### ], [====> ], etc.) # Improved pattern to avoid matching IRC channels like [#channel] barMatch = re.search(r'\[([#=\-\*]+)([\s\.]*)\]', text) if barMatch: filled = len(barMatch.group(1)) unfilled = len(barMatch.group(2)) total = filled + unfilled # Require at least 2 progress chars total and unfilled portion must be spaces/dots if total >= 2 and (not barMatch.group(2) or re.match(r'^[\s\.]*$', barMatch.group(2))): percentage = (filled / total) * 100 if percentage != self.env['commandBuffer']['lastProgressValue']: self.playProgressTone(percentage) self.env['commandBuffer']['lastProgressValue'] = percentage self.env['commandBuffer']['lastProgressTime'] = currentTime return # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) activityPattern = re.search(r'(loading|processing|working|installing|downloading|compiling|building).*\.{2,}', text, re.IGNORECASE) if activityPattern: # Play a steady beep every 2 seconds for ongoing activity if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: self.playActivityBeep() self.env['commandBuffer']['lastProgressTime'] = currentTime def playProgressTone(self, percentage): # Map 0-100% to 400-1200Hz frequency range frequency = 400 + (percentage * 8) frequency = max(400, min(1200, frequency)) # Clamp to safe range # Use Sox directly for clean quiet tones like: play -qn synth .1 tri 400 gain -8 self.playQuietTone(frequency, 0.1) def playActivityBeep(self): # Single tone for generic activity self.playQuietTone(800, 0.08) def playQuietTone(self, frequency, duration): """Play a quiet tone using Sox directly""" import subprocess import shlex # Build the Sox command: play -qn synth tri gain -8 command = f"play -qn synth {duration} tri {frequency} gain -8" try: # Only play if sound is enabled if self.env['runtime']['settingsManager'].getSettingAsBool('sound', 'enabled'): subprocess.Popen(shlex.split(command), stdin=None, stdout=None, stderr=None, shell=False) except Exception as e: self.env['runtime']['debug'].writeDebugOut("Sox tone error: " + str(e), debug.debugLevel.ERROR) def isCurrentLinePrompt(self): """Check if the current line looks like a standalone prompt (not command with progress)""" import re try: # Get the current screen content if not self.env['screen']['newContentText']: return False lines = self.env['screen']['newContentText'].split('\n') if not lines: return False # Check the last line (most common) and current cursor line for prompt patterns linesToCheck = [] # Add last line (most common for prompts) if lines: linesToCheck.append(lines[-1]) # Add current cursor line if different from last line if (self.env['screen']['newCursor']['y'] < len(lines) and self.env['screen']['newCursor']['y'] != len(lines) - 1): linesToCheck.append(lines[self.env['screen']['newCursor']['y']]) # Standalone prompt patterns (no commands mixed in) standalonePromptPatterns = [ r'^\s*\$\s*$', # Just $ (with whitespace) r'^\s*#\s*$', # Just # (with whitespace) r'^\s*>\s*$', # Just > (with whitespace) r'^\[.*\]\s*[\\\$#>]\s*$', # [path]$ without commands r'^[a-zA-Z0-9._-]+[\\\$#>]\s*$', # bash-5.1$ without commands # Interactive prompt patterns (these ARE standalone) r'.*\?\s*\[[YyNn]/[YyNn]\]\s*$', # ? [Y/n] or ? [y/N] style r'.*\?\s*\[[Yy]es/[Nn]o\]\s*$', # ? [Yes/No] style r'.*continue\?\s*\[[YyNn]/[YyNn]\].*$', # "continue? [Y/n]" style r'^::.*\?\s*\[[YyNn]/[YyNn]\].*$', # pacman style prompts # Authentication prompts (these ARE standalone) r'^\[[Ss]udo\]\s*[Pp]assword\s*for\s+.*:\s*$', # [sudo] password r'^[Pp]assword\s*:\s*$', # Password: r'.*[Pp]assword\s*:\s*$', # general password prompts # Continuation prompts (these ARE standalone) r'^[Pp]ress\s+any\s+key\s+to\s+continue.*$', # Press any key r'^[Aa]re\s+you\s+sure\?\s*.*$', # Are you sure? ] for line in linesToCheck: line = line.strip() if not line: continue # Check if this line contains both a prompt AND other content (like commands) # If so, don't treat it as a standalone prompt hasPromptMarker = bool(re.search(r'.*@.*[\\\$#>]', line) or re.search(r'^\[.*\]\s*[\\\$#>]', line)) if hasPromptMarker: # If line has prompt marker but also has significant content after it, # it's a command line, not a standalone prompt promptEnd = max( line.rfind('$'), line.rfind('#'), line.rfind('>'), line.rfind('\\') ) if promptEnd >= 0 and promptEnd < len(line) - 5: # More than just whitespace after prompt continue # This is a command line, not a standalone prompt for pattern in standalonePromptPatterns: try: if re.search(pattern, line): return True except re.error: continue return False except Exception: # If anything fails, assume it's not a prompt to be safe return False def setCallback(self, callback): pass