fenrir/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py

246 lines
12 KiB
Python

#!/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 <duration> tri <frequency> 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