diff --git a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift index af1123e2..ca7bbaed 100644 --- a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift @@ -232,12 +232,37 @@ private struct ClaudeScreenRegions: Sendable { nonisolated private static func liveStatusBlock(_ rows: [String]) -> ArraySlice { var start = rows.endIndex - while start > rows.startIndex, isClaudeLiveChromeRow(rows[start - 1]) { - start -= 1 + while start > rows.startIndex { + if isClaudeLiveChromeRow(rows[start - 1]) { + start -= 1 + continue + } + guard let head = queuedMessageHeadIndex(above: start - 1, in: rows) else { break } + start = head } return rows[start...] } + // A prose row sits inside the live block only as a queued-message body + // paragraph: a blank line inside a queued "❯" message detaches the paragraph + // from its head row, so it surfaces as an unmarked row and would otherwise + // end the block, demoting the spinner above to history while the agent is + // still working. Body paragraphs hang directly under their head, so the + // prose run counts as queued body only when the first row-starting or + // chrome row above it is a "❯" head. Anything else on top — a "⏺" block, a + // border, or non-queued chrome such as a quoted spinner — means the prose + // is transcript text, which still ends the block. + nonisolated private static func queuedMessageHeadIndex(above proseIndex: Int, in rows: [String]) -> Int? { + var index = proseIndex + while index > rows.startIndex { + let row = rows[index - 1] + if row.first == "❯" { return index - 1 } + if isClaudeLiveChromeRow(row) || claudeRowStartsNewRow(row) { return nil } + index -= 1 + } + return nil + } + nonisolated private static func currentInteractionLines( screenLines: [String], promptIndex: Int? diff --git a/supacodeTests/ClaudeScreenProfileTests.swift b/supacodeTests/ClaudeScreenProfileTests.swift index de67816d..11ffa225 100644 --- a/supacodeTests/ClaudeScreenProfileTests.swift +++ b/supacodeTests/ClaudeScreenProfileTests.swift @@ -172,6 +172,38 @@ struct ClaudeScreenProfileTests { #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.spinner)) } + @Test func spinnerAboveQueuedMessageWithBlankLineStaysWorking() { + // Live capture: a queued "❯" message whose body contains a blank line + // renders its trailing paragraph as an unmarked prose row. The bottom-up + // shape scan must not end the live block there — the paragraph still + // belongs to the queued block, and stopping demoted the spinner above it + // to history, so a working agent read as idle. + let text = """ + ⏺ Waiting for a new run to appear on the poc repo · 2m 35s + ⎿ $ until gh api "repos/acme/poc/actions/runs" >/dev/null; do sleep 5; done (2m 35s) + (ctrl+b to run in background) + + ✻ Combobulating… (14m 4s · ↓ 141.9k tokens) + + ❯ https://example.com/acme/poc/actions/runs/18352009 + + 这个是你发的么 + ❯ https://example.com/acme/poc/actions/runs/18352094 + 还有这个 + + ────────────────────────────────────────── + ❯ Press up to edit queued messages + ────────────────────────────────────────── + ✓ Bash ×18 | ✓ Edit ×1 + ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents + """ + + let detection = DetectedAgent.claude.detectScreen(in: text) + + #expect(detection.state == .working) + #expect(detection.reason == .matched(ClaudeScreenProfile.RuleID.spinner)) + } + @Test func spinnerQuotedAboveTranscriptBlockStaysIdle() { // A status row quoted in the transcript sits above a "⏺" block head. The // live block scan stops at that head, so reading the full active screen