229 lines
6.6 KiB
Go
229 lines
6.6 KiB
Go
package app
|
|
|
|
import (
|
|
"regexp"
|
|
|
|
"github.com/hjbdev/patterm/internal/preset"
|
|
)
|
|
|
|
// IdleState is the classifier's opinion about what a child is doing.
|
|
// Inspired by Solo's five-state model. ERROR is a terminal state — set
|
|
// when a child exits non-zero or matches an error-promoter regex —
|
|
// while the other four reflect transient runtime state.
|
|
type IdleState string
|
|
|
|
const (
|
|
StateUnknown IdleState = ""
|
|
StateIdle IdleState = "idle"
|
|
StateWorking IdleState = "working"
|
|
StateThinking IdleState = "thinking"
|
|
StatePermission IdleState = "permission"
|
|
StateError IdleState = "error"
|
|
)
|
|
|
|
// IdleStrategy picks the primary signal used to decide idle vs working.
|
|
// Promoter regexes can override this on top.
|
|
type IdleStrategy string
|
|
|
|
const (
|
|
StrategyOutputActivity IdleStrategy = "output_activity"
|
|
StrategyOSCTitleStability IdleStrategy = "osc_title_stability"
|
|
StrategyOSCTitleStatus IdleStrategy = "osc_title_status"
|
|
)
|
|
|
|
// defaultIdleThresholdMS is used when a preset doesn't override it.
|
|
const defaultIdleThresholdMS = 2000
|
|
|
|
// resolvedIdleDetection is the compiled, runtime-ready form of a
|
|
// preset.IdleDetection block. Built once at child spawn and held
|
|
// read-only by the classifier; regex patterns are compiled here so the
|
|
// hot path doesn't pay for it.
|
|
type resolvedIdleDetection struct {
|
|
strategy IdleStrategy
|
|
idleThresholdMS int64
|
|
|
|
titleStatusMap map[string]IdleState
|
|
|
|
permissionRegexes []*regexp.Regexp
|
|
thinkingRegexes []*regexp.Regexp
|
|
errorRegexes []*regexp.Regexp
|
|
}
|
|
|
|
// resolveIdleDetection compiles a preset.IdleDetection (which may be
|
|
// nil) into the runtime form. Unknown strategies fall back to
|
|
// output_activity. Pattern compile errors are skipped silently — the
|
|
// preset loader is responsible for surfacing them as warnings.
|
|
func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection {
|
|
r := &resolvedIdleDetection{
|
|
strategy: StrategyOutputActivity,
|
|
idleThresholdMS: defaultIdleThresholdMS,
|
|
}
|
|
if cfg == nil {
|
|
return r
|
|
}
|
|
switch IdleStrategy(cfg.Strategy) {
|
|
case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity:
|
|
r.strategy = IdleStrategy(cfg.Strategy)
|
|
}
|
|
if cfg.IdleThresholdMS > 0 {
|
|
r.idleThresholdMS = int64(cfg.IdleThresholdMS)
|
|
}
|
|
if len(cfg.TitleStatusMap) > 0 {
|
|
r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap))
|
|
for k, v := range cfg.TitleStatusMap {
|
|
switch IdleState(v) {
|
|
case StateIdle, StateWorking, StateThinking, StatePermission, StateError:
|
|
r.titleStatusMap[k] = IdleState(v)
|
|
}
|
|
}
|
|
}
|
|
r.permissionRegexes = compilePatterns(cfg.PermissionPatterns)
|
|
r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns)
|
|
r.errorRegexes = compilePatterns(cfg.ErrorPatterns)
|
|
return r
|
|
}
|
|
|
|
func compilePatterns(ps []string) []*regexp.Regexp {
|
|
if len(ps) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*regexp.Regexp, 0, len(ps))
|
|
for _, p := range ps {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
re, err := regexp.Compile(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, re)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// classify computes the IdleState from the inputs the classifier loop
|
|
// has already gathered. Pure function so it's easy to unit-test.
|
|
//
|
|
// Resolution order:
|
|
// 1. terminal: process exited non-zero → error (latched)
|
|
// 2. error-promoter regex match in recent output → error
|
|
// 3. permission-promoter regex match → permission
|
|
// 4. thinking-promoter regex match → thinking
|
|
// 5. strategy-specific base classification (idle vs working).
|
|
//
|
|
// inputs:
|
|
// - exited: whether the child process has exited
|
|
// - exitNonZero: whether the exit was non-zero (only meaningful when exited)
|
|
// - idleMS: ms since the last PTY output
|
|
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
|
|
// - title: current OSC title
|
|
// - tail: recent output bytes for regex matching
|
|
// - screen: current rendered screen text for persistent prompt matching
|
|
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail, screen []byte) (IdleState, string) {
|
|
if exited {
|
|
if exitNonZero {
|
|
return StateError, "process exited non-zero"
|
|
}
|
|
return StateIdle, "process exited cleanly"
|
|
}
|
|
if cfg == nil {
|
|
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
|
|
}
|
|
if len(tail) > 0 || len(screen) > 0 {
|
|
if matchAny(cfg.errorRegexes, tail, screen) {
|
|
return StateError, "error regex matched"
|
|
}
|
|
if matchAny(cfg.permissionRegexes, tail, screen) {
|
|
return StatePermission, "permission regex matched"
|
|
}
|
|
if matchAny(cfg.thinkingRegexes, tail, screen) {
|
|
return StateThinking, "thinking regex matched"
|
|
}
|
|
}
|
|
threshold := cfg.idleThresholdMS
|
|
switch cfg.strategy {
|
|
case StrategyOSCTitleStatus:
|
|
// First try the title-status map; if no match, fall back to
|
|
// title-stability behaviour so we still produce idle/working.
|
|
if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok {
|
|
return s, "title status match"
|
|
}
|
|
fallthrough
|
|
case StrategyOSCTitleStability:
|
|
// If we've never seen a title, fall back to output activity so
|
|
// we don't latch in idle while the child is clearly running.
|
|
if titleIdleMS == 0 {
|
|
return baseStateFromIdleMS(idleMS, threshold)
|
|
}
|
|
return baseStateFromIdleMS(titleIdleMS, threshold)
|
|
default: // output_activity
|
|
return baseStateFromIdleMS(idleMS, threshold)
|
|
}
|
|
}
|
|
|
|
func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
|
|
// idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as
|
|
// not-idle so we don't classify a freshly-spawned child as idle.
|
|
if idleMS == 0 {
|
|
return StateWorking, "no activity yet"
|
|
}
|
|
if idleMS < threshold {
|
|
return StateWorking, "recent activity"
|
|
}
|
|
return StateIdle, "quiet for threshold"
|
|
}
|
|
|
|
func matchAny(res []*regexp.Regexp, texts ...[]byte) bool {
|
|
for _, re := range res {
|
|
for _, text := range texts {
|
|
if len(text) > 0 && re.Match(text) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) {
|
|
if len(m) == 0 || title == "" {
|
|
return StateUnknown, false
|
|
}
|
|
for k, v := range m {
|
|
if k == "" {
|
|
continue
|
|
}
|
|
if containsFold(title, k) {
|
|
return v, true
|
|
}
|
|
}
|
|
return StateUnknown, false
|
|
}
|
|
|
|
// containsFold reports whether s contains sub, case-insensitively.
|
|
// Cheap implementation suitable for short titles.
|
|
func containsFold(s, sub string) bool {
|
|
if len(sub) == 0 {
|
|
return true
|
|
}
|
|
if len(sub) > len(s) {
|
|
return false
|
|
}
|
|
ls, lsub := lower(s), lower(sub)
|
|
for i := 0; i+len(lsub) <= len(ls); i++ {
|
|
if ls[i:i+len(lsub)] == lsub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func lower(s string) string {
|
|
b := []byte(s)
|
|
for i, c := range b {
|
|
if c >= 'A' && c <= 'Z' {
|
|
b[i] = c + 32
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|