package main import ( "crypto/sha256" "encoding/hex" "regexp" "strings" ) // Question is an interactive prompt detected on the agent's screen: // tool approval ("Do you want to proceed?"), folder trust, ask_question // multiple choice, auth pickers etc. type Question struct { Text string `json:"text"` Options []Option `json:"options"` Selected int `json:"selected"` // index of the ">"-marked row, -1 if unknown Numbered bool `json:"numbered"` // options can be picked with digit keys Hash string `json:"hash"` // stable id: same question => same hash } type Option struct { Num int `json:"num"` // 1-based number if the list is numbered, else 0 Label string `json:"label"` } var ( numberedRe = regexp.MustCompile(`^\s*(?:[>●○]\s*)?(\d+)\.\s+(.+?)\s*$`) selectedRe = regexp.MustCompile(`^\s*>\s+(\S.*?)\s*$`) indentedRe = regexp.MustCompile(`^\s{2,}(\S.*?)\s*$`) hintRe = regexp.MustCompile(`↑/↓|↵|esc to|ctrl\+|tab Amend|enter Confirm|Navigate|\? for shortcuts|Use Enter to select`) borderRe = regexp.MustCompile(`^[\s│╭╰╮╯]+|[\s│╭╰╮╯]+$`) ) func cleanLine(l string) string { return borderRe.ReplaceAllString(l, "") } func isHint(l string) bool { return hintRe.MatchString(l) } // optionish reports whether a raw line can belong to an option block. func optionish(raw string) bool { c := cleanLine(raw) if c == "" || isHint(c) { return false } return numberedRe.MatchString(c) || selectedRe.MatchString(c) || indentedRe.MatchString(raw) } // DetectQuestion scans the tail of a pane capture for a pending // interactive question. Returns nil when the agent is not asking. func DetectQuestion(screen string) *Question { lines := strings.Split(strings.TrimRight(screen, "\n"), "\n") if len(lines) > 45 { lines = lines[len(lines)-45:] } // Anchor: the last line that is a numbered option or a ">" selection. anchor := -1 for i := len(lines) - 1; i >= 0; i-- { c := cleanLine(lines[i]) if c == "" || isHint(c) { continue } if numberedRe.MatchString(c) || selectedRe.MatchString(c) { anchor = i break } } if anchor < 0 { return nil } // Grow the block over contiguous option-ish lines. Long numbered // labels wrap to column 0 in the TUI, so bridge over up to 3 // non-matching lines when another option row lies beyond them. start, end := anchor, anchor for i := start - 1; i >= 0; i-- { if optionish(lines[i]) { start = i continue } c := cleanLine(lines[i]) if c == "" || isHint(c) { break } bridged := false for k := i - 1; k >= i-3 && k >= 0; k-- { if numberedRe.MatchString(cleanLine(lines[k])) { start, i, bridged = k, k, true break } } if !bridged { break } } for i := end + 1; i < len(lines); i++ { if optionish(lines[i]) { end = i continue } c := cleanLine(lines[i]) if c == "" || isHint(c) { break } bridged := false for k := i + 1; k <= i+3 && k < len(lines); k++ { if numberedRe.MatchString(cleanLine(lines[k])) { end, i, bridged = k, k, true break } } if !bridged { break } } // Safety: a real prompt has a hint line within 3 rows below the // block or a ">" marker in it — a numbered markdown list in normal // agent output has neither. hasMarker := false for i := start; i <= end; i++ { if selectedRe.MatchString(cleanLine(lines[i])) || strings.HasPrefix(strings.TrimSpace(lines[i]), ">") { hasMarker = true break } } hintBelow := false for i := end + 1; i <= end+3 && i < len(lines); i++ { if isHint(cleanLine(lines[i])) { hintBelow = true break } } if !hasMarker && !hintBelow { return nil } q := &Question{Selected: -1} for i := start; i <= end; i++ { c := cleanLine(lines[i]) if c == "" || isHint(c) { continue } if m := numberedRe.FindStringSubmatch(c); m != nil { q.Options = append(q.Options, Option{Num: atoi(m[1]), Label: m[2]}) q.Numbered = true } else if m := selectedRe.FindStringSubmatch(c); m != nil { q.Options = append(q.Options, Option{Label: m[1]}) } else if q.Numbered { continue // wrapped continuation of a long numbered label } else if m := indentedRe.FindStringSubmatch(lines[i]); m != nil { q.Options = append(q.Options, Option{Label: m[1]}) } else { continue } if strings.HasPrefix(strings.TrimSpace(lines[i]), ">") { q.Selected = len(q.Options) - 1 } } if len(q.Options) < 2 { return nil } if q.Selected < 0 && !q.Numbered { return nil } // Question text: nearest lines above the block; a line ending in // "?" anchors it. var txt []string blanks := 0 for i := start - 1; i >= 0 && len(txt) < 3; i-- { c := cleanLine(lines[i]) if c == "" { blanks++ if blanks >= 2 && len(txt) > 0 { break } continue } blanks = 0 if isHint(c) { continue } txt = append([]string{c}, txt...) if strings.HasSuffix(c, "?") { break } } q.Text = strings.Join(txt, " ") if q.Text == "" { q.Text = "(fråga utan text)" } h := sha256.New() h.Write([]byte(q.Text)) for _, o := range q.Options { h.Write([]byte{0}) h.Write([]byte(o.Label)) } q.Hash = hex.EncodeToString(h.Sum(nil))[:16] return q } func atoi(s string) int { n := 0 for _, r := range s { n = n*10 + int(r-'0') } return n }