diff --git a/docs-ai/030-agent-status-detection/013-background-agent-wait-and-wrapped-rows.md b/docs-ai/030-agent-status-detection/013-background-agent-wait-and-wrapped-rows.md index e2cb71c9..cc0cb8b1 100644 --- a/docs-ai/030-agent-status-detection/013-background-agent-wait-and-wrapped-rows.md +++ b/docs-ai/030-agent-status-detection/013-background-agent-wait-and-wrapped-rows.md @@ -83,10 +83,21 @@ Two related shapes came out of the same narrow-terminal session and are covered the end of the row. Anchoring at the first `(` reads `focused)… (1m 38s` as the elapsed and rejects `● Running tests (focused)… (1m 38s · ↓ 187 tokens)`. +Assembling rows is not enough on its own, because the region that selects which rows the +rules see was still counted in physical lines. `ClaudeScreenRegions.liveStatus` took the +last three non-blank lines above the box and only then handed them to the row assembler, +so a row wrapping onto three or more continuations pushed its own head out of the window. +The head is what carries the `●` or the spinner, so the reconstructed row could not match +and the pane reported idle. Surfaces that narrow are reachable, and the shape is not +exotic: `✻ Waiting for 1 background agent to finish` needs only about twenty columns to +wrap that far. The region is now assembled into logical rows first and limited to the last +three of those, which is the same three rows whenever nothing wraps. + ## Result `claude.backgroundWork` matches the wait row in the live status region, keeping the existing below-prompt `agents done` workflow marker. `claude.elapsedStatus` accepts a free-text label and a compound elapsed segment, located from the end of the logical row. -Both read logical rows rather than physical lines. Two captured fixtures under +Both read logical rows rather than physical lines, and so does the live status region that +feeds them. Two captured fixtures under `claude/2.1.224/working/` witness the rules, and a narrow capture covers the wrapped shape. diff --git a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift index 3fbd5966..d51a693c 100644 --- a/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift +++ b/supacode/Infrastructure/AgentDetection/ClaudeScreenProfile.swift @@ -114,7 +114,7 @@ enum ClaudeScreenProfile { // elapsed segment wins. Anchoring at the first "(" instead would read // "focused)… (1m 38s" as the elapsed and reject a live row. nonisolated private static func hasElapsedStatusLine(_ content: String) -> Bool { - logicalRows(content).contains { row in + claudeLogicalRows(content).contains { row in guard row.first == "●" else { return false } let body = row.dropFirst().trimmingCharacters(in: .whitespaces) @@ -129,50 +129,6 @@ enum ClaudeScreenProfile { } } - // Claude wraps a row too wide for the pane onto indented continuation lines — - // observed at 40 columns on 2.1.225: - // - // "✻ Waiting for 1 background agent to" - // " finish" - // - // Narrow split panes are ordinary, so every rule that reads a row has to see the - // logical row rather than the first physical one. Only an adjacent line indented - // past its head and starting with ordinary text is a continuation: a line opening - // with its own marker starts a new row, which keeps this from swallowing the - // whole region and inventing rows that were never on screen. - nonisolated private static func logicalRows(_ content: String) -> [String] { - var rows: [String] = [] - var current: String? - var headIndent = 0 - - for line in content.split(separator: "\n", omittingEmptySubsequences: false) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { - if let row = current { rows.append(row) } - current = nil - continue - } - let indent = line.prefix(while: { $0 == " " }).count - - if current != nil, indent > headIndent, !startsNewRow(trimmed) { - current?.append(" ") - current?.append(trimmed) - continue - } - if let row = current { rows.append(row) } - current = trimmed - headIndent = indent - } - if let row = current { rows.append(row) } - return rows - } - - nonisolated private static func startsNewRow(_ trimmed: String) -> Bool { - guard let first = trimmed.unicodeScalars.first else { return false } - if isAgentSpinnerScalar(first) { return true } - return "●⏺◯⎿❯─-".unicodeScalars.contains(first) - } - // One or more "" tokens separated by single spaces, terminated by // the closing paren or by the " · " that separates elapsed from the rest of the // row. A partial token ("10seconds", "1st") fails the terminator check. @@ -209,7 +165,7 @@ enum ClaudeScreenProfile { // and it is scoped to the live status region so a transcript quoting it cannot // trip the rule. nonisolated private static func hasBackgroundAgentWait(_ liveStatus: String) -> Bool { - logicalRows(liveStatus).contains { row in + claudeLogicalRows(liveStatus).contains { row in guard let first = row.unicodeScalars.first, isAgentSpinnerScalar(first) else { return false } @@ -237,10 +193,15 @@ private struct ClaudeScreenRegions: Sendable { ) self.currentInteractionLines = currentInteractionLines self.currentInteractionLower = currentInteractionLines.joined(separator: "\n").lowercased() - self.liveStatus = agentDetectionRecentLines( - Self.contentAbovePrompt(screenLines: lines, promptIndex: promptIndex), - limit: 3 + // Reconstruct rows before limiting the region. Counting the limit in physical + // lines drops the head of any row that wraps onto three or more continuations, + // and the head is what carries the "●" or spinner glyph the rules key on. + // Widths that narrow are reachable: a surface can be as few as five columns. + self.liveStatus = claudeLogicalRows( + Self.contentAbovePrompt(screenLines: lines, promptIndex: promptIndex) ) + .suffix(3) + .joined(separator: "\n") self.belowPromptLines = Self.contentBelowPrompt(screenLines: lines, promptIndex: promptIndex) .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) @@ -298,6 +259,54 @@ private struct ClaudeScreenRegions: Sendable { } } +// Claude wraps a row too wide for the pane onto indented continuation lines — +// observed at 40 columns on 2.1.225: +// +// "✻ Waiting for 1 background agent to" +// " finish" +// +// Narrow split panes are ordinary, so every rule that reads a row has to see the +// logical row rather than the first physical one, and so does the region that +// selects which rows the rules see. Only an adjacent line indented past its head +// and starting with ordinary text is a continuation: a line opening with its own +// marker starts a new row, which keeps this from swallowing the whole region and +// inventing rows that were never on screen. +// +// Rows arrive already trimmed and unindented, so running this over its own output +// returns that output unchanged. +nonisolated private func claudeLogicalRows(_ content: String) -> [String] { + var rows: [String] = [] + var current: String? + var headIndent = 0 + + for line in content.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + if let row = current { rows.append(row) } + current = nil + continue + } + let indent = line.prefix(while: { $0 == " " }).count + + if current != nil, indent > headIndent, !claudeRowStartsNewRow(trimmed) { + current?.append(" ") + current?.append(trimmed) + continue + } + if let row = current { rows.append(row) } + current = trimmed + headIndent = indent + } + if let row = current { rows.append(row) } + return rows +} + +nonisolated private func claudeRowStartsNewRow(_ trimmed: String) -> Bool { + guard let first = trimmed.unicodeScalars.first else { return false } + if isAgentSpinnerScalar(first) { return true } + return "●⏺◯⎿❯─-".unicodeScalars.contains(first) +} + nonisolated private func isClaudeNumberedSelectionLine(_ line: String) -> Bool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.first == "❯" else { return false } diff --git a/supacodeTests/ClaudeBackgroundAgentDetectionTests.swift b/supacodeTests/ClaudeBackgroundAgentDetectionTests.swift index 9699ed6b..0583e10e 100644 --- a/supacodeTests/ClaudeBackgroundAgentDetectionTests.swift +++ b/supacodeTests/ClaudeBackgroundAgentDetectionTests.swift @@ -152,6 +152,47 @@ struct ClaudeBackgroundAgentDetectionTests { #expect(wrappedElapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) } + @Test func rowWrappedPastTheRegionLimitKeepsItsHead() { + // The live status region is the last three rows above the box. A row narrow + // enough to wrap onto four or more physical lines pushes its own head out of + // a window counted in physical lines, and the head carries the glyph every + // rule keys on. The region is therefore reconstructed into logical rows + // before the limit applies. + let wrappedWait = detect( + """ + ⏺ Launched it. + + ✻ Waiting + for 1 + background + agent to + finish + ──── + ❯ + ──── + """ + ) + #expect(wrappedWait.state == .working) + #expect(wrappedWait.reason == .matched(ClaudeScreenProfile.RuleID.backgroundWork)) + + let wrappedElapsed = detect( + """ + ⏺ Started. + + ● Running + gates… + (1m 38s · + ↓ 187 + tokens) + ──── + ❯ + ──── + """ + ) + #expect(wrappedElapsed.state == .working) + #expect(wrappedElapsed.reason == .matched(ClaudeScreenProfile.RuleID.elapsedStatus)) + } + @Test func continuationJoiningDoesNotInventRows() { // A following line that carries its own marker starts a new row rather than // continuing the previous one, so nothing is stitched into a status row that