After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,92 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
func snapshot(lines ...string) screen.Snapshot {
|
||||
result := screen.Snapshot{Terminal: 1, Active: true, Rows: len(lines), Cols: 100, Y: len(lines) - 1}
|
||||
for _, line := range lines {
|
||||
cells := make([]rune, result.Cols)
|
||||
for index := range cells {
|
||||
cells[index] = ' '
|
||||
}
|
||||
copy(cells, []rune(line))
|
||||
result.Cells = append(result.Cells, cells...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestRecognizesFreshPromptsAndCompletion(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"$", "#", ">", "bash-5.3$", "[Username@Host ~/code] $", "Username@Host:~$",
|
||||
"Proceed? [Y/n]", "Continue? (Yes/No)", "Is this ok [y/N]:", ":: Proceed? [Y/n]",
|
||||
"Password:", "[sudo] password for Username:", "Username's password:",
|
||||
"Please enter passphrase for key:", "Press any key to continue...", "Are you sure? [yes/no]",
|
||||
"Please confirm:", "CUSTOM READY", "Device 42 ready:",
|
||||
"Worked for 3m 35s • 8:05 PM", "Worked for 19s • 7:46 PM", "Worked for 1h 2m 3s • 12:00 AM",
|
||||
} {
|
||||
t.Run(line, func(t *testing.T) {
|
||||
detector, err := New([]string{"CUSTOM READY"}, []string{`^Device [0-9]+ ready:$`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := detector.Observe(snapshot("working")); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := detector.Observe(snapshot(" " + line)); got != line {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := detector.Observe(snapshot(" " + line)); got != "" {
|
||||
t.Fatal("stale line restored", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsFalseAndIncompletePrompts(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"Price is $", "C# language", "Downloading 20%", "$ echo hello", "Password: secret",
|
||||
"Please enter passphrase: secret", "Worked for 19s", "Worked for 19s • 7:4",
|
||||
"Worked for 19s • 17:46 PM", "Worked for 19s • 7:99 PM", "Worked for 19s • 7:46 PM more",
|
||||
"quoted Worked for 19s • 7:46 PM", "Worked for 3m nonsense • 8:05 PM",
|
||||
} {
|
||||
t.Run(line, func(t *testing.T) {
|
||||
detector, _ := New(nil, nil)
|
||||
detector.Observe(snapshot("working"))
|
||||
if got := detector.Observe(snapshot(line)); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCompletionAboveCursorAndOldScroll(t *testing.T) {
|
||||
detector, _ := New(nil, nil)
|
||||
detector.Observe(snapshot("old", "working", "input"))
|
||||
if got := detector.Observe(snapshot("old", "Worked for 19s • 7:4", "input")); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
line := "Worked for 19s • 7:46 PM"
|
||||
if got := detector.Observe(snapshot("old", line, "input")); got != line {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := detector.Observe(snapshot(line, "input", "still working")); got != "" {
|
||||
t.Fatal("scrolled history restored", got)
|
||||
}
|
||||
// A shell prompt elsewhere on the screen is not the active prompt.
|
||||
if got := detector.Observe(snapshot(line, "$", "still working")); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionChangesOnlyEstablishBaseline(t *testing.T) {
|
||||
for _, mode := range []string{"terminal", "generation", "resize", "inactive"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
detector, _ := New(nil, nil)
|
||||
detector.Observe(snapshot("working"))
|
||||
next := snapshot("$")
|
||||
switch mode {
|
||||
case "terminal":
|
||||
next.Terminal++
|
||||
case "generation":
|
||||
next.Generation++
|
||||
case "resize":
|
||||
next = snapshot("", "$")
|
||||
case "inactive":
|
||||
next.Active = false
|
||||
}
|
||||
if got := detector.Observe(next); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
next.Active = true
|
||||
if got := detector.Observe(next); got != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user