import Foundation enum ClaudeScreenProfile { enum RuleID { nonisolated static let viewer = AgentScreenRuleID("claude.viewer") nonisolated static let blockedPrompt = AgentScreenRuleID("claude.blockedPrompt") nonisolated static let spinner = AgentScreenRuleID("claude.spinner") nonisolated static let elapsedStatus = AgentScreenRuleID("claude.elapsedStatus") nonisolated static let backgroundWork = AgentScreenRuleID("claude.backgroundWork") nonisolated static let idleComposer = AgentScreenRuleID("claude.idleComposer") // Keep exhaustive so prefix and uniqueness tests cover every emitted ID. nonisolated static let all = [ viewer, blockedPrompt, spinner, elapsedStatus, backgroundWork, idleComposer, ] } nonisolated static func detect(in snapshot: AgentScreenSnapshot) -> AgentScreenDetection { let regions = ClaudeScreenRegions(snapshot: snapshot) if hasViewerChrome(regions) { return AgentScreenDetection(state: .unknown, reason: .matched(RuleID.viewer)) } if hasBlockedPrompt(regions) { return AgentScreenDetection(state: .blocked, reason: .matched(RuleID.blockedPrompt)) } if hasSpinnerActivity(regions.liveStatus) { return AgentScreenDetection(state: .working, reason: .matched(RuleID.spinner)) } if hasElapsedStatusLine(regions.liveStatus) { return AgentScreenDetection(state: .working, reason: .matched(RuleID.elapsedStatus)) } if hasBackgroundAgentWait(regions.liveStatus) || regions.belowPromptLines.joined(separator: "\n").lowercased().contains("agents done") { return AgentScreenDetection(state: .working, reason: .matched(RuleID.backgroundWork)) } if regions.hasIdleComposer { return AgentScreenDetection(state: .idle, reason: .matched(RuleID.idleComposer)) } return AgentScreenDetection(state: .idle, reason: .noRuleMatched) } /// A recognized composer, including wrapped draft rows. Nil means its boundaries /// cannot be established and must not be interpreted as an empty input. nonisolated static func composerContents(in snapshot: AgentScreenSnapshot) -> String? { let lines = snapshot.lines guard let prompt = lines.lastIndex(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("❯") }), prompt > 0, isBoxBorderLine(lines[prompt - 1]), let end = lines.indices.dropFirst(prompt + 1).first(where: { isBoxBorderLine(lines[$0]) }) else { return nil } let first = lines[prompt].trimmingCharacters(in: .whitespaces).dropFirst() return ([String(first)] + Array(lines[(prompt + 1).. String? { let regions = ClaudeScreenRegions(snapshot: snapshot) guard hasBlockedPrompt(regions) else { return nil } let lines = regions.currentInteractionLines.isEmpty ? Array(snapshot.lines.suffix(18)) : regions.currentInteractionLines return lines.joined(separator: "\n").trimmingCharacters(in: .newlines) } nonisolated private static func hasViewerChrome(_ regions: ClaudeScreenRegions) -> Bool { if regions.hasScrollOverlay { return true } if regions.bottomChromeLines.contains(where: { line in line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") }) { return true } let lower = regions.bottomViewerLines.joined(separator: "\n").lowercased() return lower.contains("⌕ filter history") && lower.contains("↑/↓ to nav") && lower.contains("enter to use") && lower.contains("esc to cancel") && lower.contains("ctrl+s to scope") } nonisolated private static func hasBlockedPrompt(_ regions: ClaudeScreenRegions) -> Bool { let lower = regions.currentInteractionLower if lower.contains("do you want to proceed?") || lower.contains("would you like to proceed?") || lower.contains("waiting for permission") || lower.contains("do you want to allow this connection?") || lower.contains("tab to amend") || lower.contains("ctrl+e to explain") || lower.contains("chat about this") || lower.contains("review your answers") || lower.contains("skip interview and plan immediately") || hasWorkspaceTrustPrompt(lower) { return true } return hasConfirmationPrompt(lower) || (hasSelectionPrompt(regions.currentInteractionLines) && hasYesNoChoice(regions.currentInteractionLines)) } // Newer Claude releases render this initial gate as plain "No, exit" / // "Yes, I trust this folder" rows instead of a numbered menu. Keep the full prompt // signature so the user entering one of those phrases into the composer // cannot be mistaken for a live blocker. nonisolated private static func hasWorkspaceTrustPrompt(_ lower: String) -> Bool { lower.contains("quick safety check:") && lower.contains("claude code'll be able to read, edit, and execute files here.") && lower.contains("no, exit") && lower.contains("yes, i trust this folder") && lower.contains("enter to confirm") } nonisolated private static func hasSelectionPrompt(_ lines: [String]) -> Bool { lines.contains(where: isClaudeNumberedSelectionLine) } nonisolated private static func hasYesNoChoice(_ lines: [String]) -> Bool { lines.contains { line in let line = line.trimmingCharacters(in: .whitespaces) let option = line.hasPrefix("❯") ? String(line.dropFirst()).trimmingCharacters(in: .whitespaces) : line let trimmed = option.lowercased() return trimmed == "yes" || trimmed == "no" || trimmed.hasPrefix("1. yes") || trimmed.hasPrefix("2. no") || trimmed.hasPrefix("yes, and ") || trimmed.hasPrefix("no, and tell claude") } } // Claude's live status row is "●