// 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 . package progress import ( "slices" "strings" "time" "git.stormux.org/storm/fenrir/internal/diagnostic" "git.stormux.org/storm/fenrir/internal/screen" ) // Monitor owns detection state on the reader loop. No goroutines, subprocesses, // whole-screen strings, or regexp compilation are needed for an update. type Monitor struct { active bool pending *Indicator last Indicator lastAt time.Time } func (m *Monitor) Reset() { *m = Monitor{} } // Update ignores editingRow when a screen-confirmed input deletion changed it. // Pass -1 when no row should be excluded. Other output remains eligible. func (m *Monitor) Update(previous, next screen.Snapshot, editingRow int) { reason := "session-change" changedCount, retainedCount, candidateCount := 0, 0, 0 if diagnostic.Enabled() { defer func() { diagnostic.Record("progress detection", "reason", reason, "changed_rows", changedCount, "retained_rows", retainedCount, "candidate_rows", candidateCount) }() } if !next.Active || !previous.Active || previous.Terminal != next.Terminal || previous.Generation != next.Generation || previous.Rows != next.Rows || previous.Cols != next.Cols || next.Rows == 0 || next.Cols == 0 { m.Reset() return } reason = "prompt" if promptPattern.MatchString(next.Text(next.Y)) || promptPattern.MatchString(next.Text(next.Rows-1)) { m.Reset() return } reason = "cursor-jump" if next.Y != previous.Y && (abs(next.Y-previous.Y) > 2 || abs(next.X-previous.X) > 20) { m.Reset() return } // A cursor move over unchanged text is navigation, not ongoing work. reason = "unchanged" if slices.Equal(previous.Cells, next.Cells) { if previous.X != next.X || previous.Y != next.Y { m.Reset() } return } // Scrolling moves old output through many rows. Remove the retained // overlap before applying size/multiline filters to genuinely new output. var changedRows []int for row := range next.Rows { if !slices.Equal(previous.Line(row), next.Line(row)) { changedRows = append(changedRows, row) } } changedCount = len(changedRows) scrollStart, scrollEnd := 0, 0 if len(changedRows) > 1 { scrollStart, scrollEnd = screen.Scrolled_overlap(previous, next) } var lines []string length := 0 for _, row := range changedRows { if row == editingRow { continue } if row >= scrollStart && row < scrollEnd { retainedCount++ continue } line := next.Text(row) // Erased rows are not new multiline output (common after a scroll). if strings.TrimSpace(line) == "" { continue } lines = append(lines, line) length += len([]rune(line)) } reason = "no-pattern" if editingRow >= 0 && len(lines) == 0 { reason = "editing" } var selected Indicator found := false for _, line := range lines { if (length > 200 || len(lines) > 1) && !explicit(line) { reason = "repaint-filter" continue } candidateCount++ if value, ok := Detect(strings.TrimSpace(line)); ok { selected, found = value, true } } if !found { m.Reset() return } // Replace pending output even when throttled. The final value is flushed by // the loop's existing timer if the program finishes between two audio ticks. reason = "measured" if selected.Interval != 0 { reason = "activity" } m.active = true m.pending = &selected } func (m *Monitor) Next(now time.Time) (float64, bool) { if m.pending == nil { return 0, false } value := *m.pending interval := value.Interval if interval == 0 { interval = 100 * time.Millisecond } if !m.lastAt.IsZero() && now.Sub(m.lastAt) < interval { return 0, false } m.pending = nil if value.Interval == 0 && !m.lastAt.IsZero() && value == m.last { return 0, false } m.last, m.lastAt = value, now return value.Percentage, true } func abs(value int) int { if value < 0 { return -value } return value } func (m *Monitor) Active() bool { return m.active }