diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 7de89063..0206f08d 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -35,7 +35,9 @@ To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) hold so brief pauses between thinking and output don't drop it out of "working" (a genuine finish therefore reports up to ~3s late; "blocked" -bypasses the hold and surfaces immediately). +bypasses the hold and surfaces immediately). Viewer overlays (Claude's +transcript / history-search views) cover the live status area, so frames +showing their chrome keep the last trusted state instead of forcing idle. ## The state machine diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index d13b9b48..e336f0c1 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -92,12 +92,20 @@ func stabilizeAgentState( return .working case .blocked: return .blocked + case .unknown: + // A viewer overlay (transcript, history search) is covering the live + // status area, so this frame carries no signal: keep the last trusted + // state, and keep the working hold alive while the screen stays covered. + if previous == .working { + lastWorkingAt = now + } + return previous case .idle where previous == .working: guard let lastWorkingAt else { return .idle } return now.timeIntervalSince(lastWorkingAt) < workingStateHold ? .working : .idle - case .idle, .unknown: + case .idle: return raw } } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 44b4b6ad..86feb619 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -56,10 +56,8 @@ nonisolated private func detectPi(_ content: String) -> AgentRawState { } nonisolated private func detectClaude(_ content: String) -> AgentRawState { - let lower = content.lowercased() - - if content.contains("⌕ Search…") || lower.contains("ctrl+r to toggle") { - return .idle + if hasClaudeViewerChrome(content) { + return .unknown } let currentInteraction = claudeCurrentInteractionRegion(content) if hasClaudeBlockedPrompt(content: currentInteraction, lower: currentInteraction.lowercased()) { @@ -235,6 +233,22 @@ nonisolated private func detectAmp(_ content: String) -> AgentRawState { return .idle } +// Transcript (ctrl+r/ctrl+o) and history-search views cover the live status +// area, so a frame showing their chrome carries no task-state signal — the +// caller maps `.unknown` to "keep the previous state". The hint strings are +// only trusted on the bottom chrome lines: matching them anywhere on screen +// misreads conversation text that merely quotes them (e.g. a discussion +// about these very heuristics). +nonisolated private func hasClaudeViewerChrome(_ content: String) -> Bool { + let bottomLines = content.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .suffix(3) + return bottomLines.contains { line in + line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") + } +} + nonisolated private func contentAbovePromptBox(_ content: String) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index d7f0c16b..62987f51 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -73,6 +73,50 @@ struct PaneAgentStateTests { #expect(blocked == .blocked) } + @Test func unknownObservationKeepsPreviousStateAndRefreshesHold() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? = now + let later = now.addingTimeInterval(10) + + let held = stabilizeAgentState( + agent: .claude, + previous: .working, + raw: .unknown, + now: later, + lastWorkingAt: &lastWorking + ) + + #expect(held == .working) + #expect(lastWorking == later) + + var noHistory: Date? + let idle = stabilizeAgentState( + agent: .claude, + previous: .idle, + raw: .unknown, + now: later, + lastWorkingAt: &noHistory + ) + + #expect(idle == .idle) + #expect(noHistory == nil) + } + + @Test func unknownObservationWithoutHistoryStaysUnknown() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? + + let unknown = stabilizeAgentState( + agent: .claude, + previous: .unknown, + raw: .unknown, + now: now, + lastWorkingAt: &lastWorking + ) + + #expect(unknown == .unknown) + } + @Test func presenceRequiresSixMissesBeforeRelease() { var presence = AgentDetectionPresence(currentAgent: .codex) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index caaf3f50..56c9c2a7 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -177,6 +177,49 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeViewerChromeAtBottomCarriesNoSignal() { + #expect( + DetectedAgent.claude.detectState( + in: """ + ✻ Tempering… (12s · esc to interrupt) + older transcript content + ctrl+r to toggle + """ + ) == .unknown + ) + #expect( + DetectedAgent.claude.detectState( + in: """ + Task complete. + ⌕ Search… + ↑↓ to navigate + """ + ) == .unknown + ) + } + + @Test func claudeQuotedViewerHintMidConversationDoesNotForceIdle() { + // Regression: a chat message quoting "ctrl+r to toggle" used to force + // idle while the spinner below showed Claude still working. + #expect( + DetectedAgent.claude.detectState( + in: """ + ⏺ 收尾完成,现状如下: + + ❯ 3. 修一个我们现存的 bug:detectClaude 里 ctrl+r to toggle → 强制 idle,意味着 + Claude working 时按 ctrl+o/ctrl+r 看 transcript 会闪成 Done。 + + 这个仔细看看,你觉得有必要的话,可以修一下 + + ✻ Twisting… (34s · ↓ 1.8k tokens · thinking more with xhigh effort) + ───────── + ❯ + ───────── + """ + ) == .working + ) + } + @Test func codexDetection() { #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working)