From 16e0964ce3a68e8b4fabfe24019ba9f1fc2cc140 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 21 Aug 2026 17:33:58 +0900 Subject: [PATCH] fix(detection): keep queued-message body paragraphs in the Claude live block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued "❯" message containing a blank line renders its trailing paragraph as an unmarked prose row: claudeLogicalRows treats the blank line as a row terminator, so the paragraph detaches from its head. The bottom-up shape scan ended the live block at that row, demoted the spinner above it to history, and a working agent read as idle until the queue drained. Body paragraphs hang directly under their "❯" head, so the scan now keeps a prose run only when the first row-starting or chrome row above it is a queued head. A "⏺" block, a border, or non-queued chrome such as a quoted spinner still ends the block, so transcript-quoted status rows stay history. Co-Authored-By: Claude Fable 5 --- .../AgentDetection/ClaudeScreenProfile.swift | 29 +++++++++++++++-- supacodeTests/ClaudeScreenProfileTests.swift | 32 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) 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 -- 2.51.2