// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Detection patterns adapted from Python Fenrir (Chrys, Storm Dragon, and contributors). // // Package progress recognizes terminal progress without performing I/O. package progress import ( "regexp" "strconv" "strings" "time" "unicode/utf8" ) type Indicator struct { Percentage float64 Interval time.Duration // zero for measured progress, otherwise activity } var ( percentPattern = regexp.MustCompile(`(?:^|[^\d.\-])(\d+(?:\.\d+)?)\s*%`) statsPattern = regexp.MustCompile(`(?i)\b(?:humidity|cpu|memory|disk|usage|temp|weather|forecast)\b`) fractionPattern = regexp.MustCompile(`\b(\d+)\s*(?:of|/)\s*(\d+)\b`) fractionNoise = regexp.MustCompile(`(?i)\b(?:page|chapter|section|line|row|column|year|month|day)\b|\d{1,2}/\d{1,2}/\d{2,4}`) barPattern = regexp.MustCompile(`\[[#=*>█▉▊▋▌▍▎▏▒▓░. \t-]{2,}\]|\|[#=*>█▉▊▋▌▍▎▏▒▓░. \t-]{2,}\|`) urlPattern = regexp.MustCompile(`(?i)\S+://\S+|www\.[^\s]+\.[a-z]{2,}`) ratePattern = regexp.MustCompile(`(?i)\b\d+(?:\.\d+)?\s*[kmgt](?:i?b)?/s\b`) fieldPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9_-]*)=\s*\S+`) timeField = regexp.MustCompile(`(?i)\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b`) rateField = regexp.MustCompile(`(?i)\b(?:speed|fps|bitrate)=\s*[\d.]+[a-z/]*\b`) transferPattern = regexp.MustCompile(`(?i)(?:remaining|elapsed|left|eta)[:;\s]*\d+s|\d+s\s+(?:remaining|elapsed|left)|(?:processing|generating|used|consumed)\s+\d+\s+tokens|\d+\s+bytes.*copied.*\d+\s+s.*[kmgt]?b/s|\d+(?:\.\d+)?\s+[kmgt]ib\s+\d+(?:\.\d+)?\s+[kmgt]ib/s\s+\d+:\d+|\d+\s+\d+[kmgt]?\s+\d+\s+\d+[kmgt]?.*\d+\.\d+[kmgt].*\d+:\d+:\d+`) activityWords = `thinking|working|processing|analyzing|searching|reading|writing|planning|running|executing|updating|building|installing|compiling|downloading|reviewing|generating|responding|applying|fixing|editing|creating|preparing|checking|opening|loading|fetching|retrieving|scanning|indexing|summarizing` activityPattern = regexp.MustCompile(`(?i)\b(?:loading|processing|working|installing|downloading|compiling|building)\b.*\.{2,}`) interruptPattern = regexp.MustCompile(`(?i)(?:[*✢✽✶✻·•◦○●◆]\s+\S+|^\s*(?:` + activityWords + `)\b).*\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)`) bulletPattern = regexp.MustCompile(`(?i)^\s*[•◦]\s+.*(?:…|\.{3,}|\b(?:` + activityWords + `)\b)`) thinkingPattern = regexp.MustCompile(`(?i)🔄[^\pL\d_]*(?:thinking|processing|working|analyzing)[^\d]*(\d+)s?\b`) taskPattern = regexp.MustCompile(`(?i)[●○◉]\s+(?:task\s+)?(?:output|running|pending|working|processing)\s+[a-z0-9]+`) promptPattern = regexp.MustCompile(`(?i)^\s*(?:[$#>]|\[.*\]\s*[\\$#>]|[a-z0-9._-]+[\\$#>]|\S+@\S+.*[$#>])\s*$|\?\s*\[(?:y/n|yes/no)\]\s*$|(?:\[sudo\]\s*password for .*|password)\s*:\s*$|^\s*(?:press any key to continue|are you sure\?).*$`) curlPattern = regexp.MustCompile(`^\s*(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+(\d+)\s+` + sizeField + `\s+` + sizeField + `\s+` + sizeField + `\s+` + clockField + `\s+` + clockField + `\s+` + clockField + `\s+` + sizeField + `(?:\s|$)`) ) const sizeField = `\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?` const clockField = `(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)` func curl_percentage(text string) (float64, bool) { match := curlPattern.FindStringSubmatch(text) if match == nil { return 0, false } total, _ := strconv.ParseFloat(match[1], 64) received, _ := strconv.ParseFloat(match[2], 64) uploaded, _ := strconv.ParseFloat(match[3], 64) return total, total <= 100 && total == received && uploaded <= 100 } func status_fields(text string) bool { names := make(map[string]bool) for _, match := range fieldPattern.FindAllStringSubmatch(text, -1) { switch name := strings.ToLower(match[1]); name { case "frame", "fps", "size", "time", "bitrate", "speed", "elapsed", "dup", "drop": names[name] = true } } return len(names) >= 3 && timeField.MatchString(text) && rateField.MatchString(text) } // Explicit rows are safe to inspect in large/multiline repaints. This follows // Python's structural filters instead of accepting arbitrary numeric tables. func explicit(text string) bool { if urlPattern.MatchString(text) { return false } if _, ok := curl_percentage(text); ok { return true } return status_fields(text) || percentPattern.MatchString(text) && (barPattern.MatchString(text) || ratePattern.MatchString(text)) } // Detect ports Python's screen-update detector. Prefer measured progress, then // activity. Indeterminate indicators use the same pitch as measured 50%. func Detect(text string) (Indicator, bool) { if strings.ContainsAny(text, "\r\n") || urlPattern.MatchString(text) { return Indicator{}, false } if match := percentPattern.FindStringSubmatch(text); match != nil { value, _ := strconv.ParseFloat(match[1], 64) if value <= 100 && !statsPattern.MatchString(text) { return Indicator{Percentage: value}, true } return Indicator{}, false } if value, ok := curl_percentage(text); ok { return Indicator{Percentage: value}, true } if transferPattern.MatchString(text) || status_fields(text) { return activity(2 * time.Second), true } if match := fractionPattern.FindStringSubmatch(text); match != nil && !fractionNoise.MatchString(text) { current, _ := strconv.ParseFloat(match[1], 64) total, _ := strconv.ParseFloat(match[2], 64) if total > 0 && total <= 1000 && current <= total { return Indicator{Percentage: 100 * current / total}, true } } if bar := barPattern.FindString(text); bar != "" { content := []rune(bar)[1 : utf8.RuneCountInString(bar)-1] filled, empty := 0.0, false valid := true for _, char := range content { weight := 1.0 switch char { case ' ', '\t', '.', '-', '░': weight = 0 empty = true case '▏': weight = .125 case '▎': weight = .25 case '▍': weight = .375 case '▌': weight = .5 case '▋': weight = .625 case '▊': weight = .75 case '▉': weight = .875 } if weight > 0 && empty { valid = false } filled += weight } if valid && filled > 0 { return Indicator{Percentage: 100 * filled / float64(len(content))}, true } } if activityPattern.MatchString(text) { return activity(2 * time.Second), true } if strings.ContainsAny(text, "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⡿⣟⣯⣷⣾⣽⣻⢿🌑🌒🌓🌔🌕🌖🌗🌘◐◓◒◑") || interruptPattern.MatchString(text) || bulletPattern.MatchString(text) { return activity(time.Second), true } if match := thinkingPattern.FindStringSubmatch(text); match != nil { seconds, _ := strconv.Atoi(match[1]) interval := 1500 * time.Millisecond if seconds >= 10 { interval = 2 * time.Second } return activity(interval), true } if taskPattern.MatchString(text) { return activity(1500 * time.Millisecond), true } return Indicator{}, false } func activity(interval time.Duration) Indicator { return Indicator{Percentage: 50, Interval: interval} }