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) }