93 lines
3.1 KiB
Go
93 lines
3.1 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package prompt recognizes fresh prompts and work-completion lines.
|
|
package prompt
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
var defaultPatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`^(?:[$#>]|\[.*\]\s*[\\$#>]|[a-zA-Z0-9._-]+[\\$#>]|\S+@\S+.*[$#>])\s*$`),
|
|
regexp.MustCompile(`(?i)\?\s*(?:\[(?:y/n|yes/no)\]|\((?:y/n|yes/no)\))\s*:?\s*$`),
|
|
regexp.MustCompile(`(?i)^.*\bok\s*\[y/n\]\s*:?\s*$`),
|
|
regexp.MustCompile(`(?i)^(?:\[sudo\]\s*password\s+for\s+.+|.*\bpassword|(?:please\s+)?enter\s+passphrase.*)\s*:\s*$`),
|
|
regexp.MustCompile(`(?i)^(?:press\s+any\s+key\s+to\s+continue.*|are\s+you\s+sure\?.*|please\s+confirm.*)$`),
|
|
}
|
|
|
|
var completionPattern = regexp.MustCompile(`^Worked for (?:[0-9]+h(?: [0-9]+m)?(?: [0-9]+s)?|[0-9]+m(?: [0-9]+s)?|[0-9]+s)\s+•\s+(?:[1-9]|1[0-2]):[0-5][0-9]\s+(?:AM|PM)$`)
|
|
|
|
type Detector struct {
|
|
exactMatches []string
|
|
patterns []*regexp.Regexp
|
|
previous map[string]int
|
|
terminal int
|
|
generation uint64
|
|
rows, cols int
|
|
active bool
|
|
}
|
|
|
|
func New(exactMatches, customPatterns []string) (*Detector, error) {
|
|
detector := &Detector{exactMatches: append([]string(nil), exactMatches...)}
|
|
for _, pattern := range customPatterns {
|
|
compiled, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid prompt custom pattern %q: %w", pattern, err)
|
|
}
|
|
detector.patterns = append(detector.patterns, compiled)
|
|
}
|
|
return detector, nil
|
|
}
|
|
|
|
func (d *Detector) matches_prompt(text string) bool {
|
|
if text == "" {
|
|
return false
|
|
}
|
|
for _, exact := range d.exactMatches {
|
|
if text == strings.TrimSpace(exact) {
|
|
return true
|
|
}
|
|
}
|
|
for _, patterns := range [][]*regexp.Regexp{d.patterns, defaultPatterns} {
|
|
for _, pattern := range patterns {
|
|
if pattern.MatchString(text) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Observe runs even when speech is enabled, establishing a baseline before
|
|
// silence. Counts ignore row movement, so old prompts/completions scrolling
|
|
// through the screen cannot restore speech. Session/buffer/size changes only
|
|
// establish a new baseline; they never restore from another screen's history.
|
|
// Shell/interactive prompts must be on the cursor row. Completion lines can
|
|
// appear above an input area, as in a terminal coding assistant.
|
|
func (d *Detector) Observe(next screen.Snapshot) string {
|
|
counts := make(map[string]int)
|
|
for row := 0; row < next.Rows; row++ {
|
|
counts[strings.TrimSpace(next.Text(row))]++
|
|
}
|
|
stable := d.previous != nil && d.active && next.Active && d.terminal == next.Terminal &&
|
|
d.generation == next.Generation && d.rows == next.Rows && d.cols == next.Cols
|
|
var result string
|
|
if stable {
|
|
for row := 0; row < next.Rows; row++ {
|
|
text := strings.TrimSpace(next.Text(row))
|
|
if counts[text] > d.previous[text] && (completionPattern.MatchString(text) || row == next.Y && d.matches_prompt(text)) {
|
|
result = text
|
|
break
|
|
}
|
|
}
|
|
}
|
|
d.previous = counts
|
|
d.terminal, d.generation, d.rows, d.cols, d.active = next.Terminal, next.Generation, next.Rows, next.Cols, next.Active
|
|
return result
|
|
}
|