From a0993221acc1caf1f5d1fb77db8f2bd0e7df7d4f Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Fri, 7 Aug 2026 01:17:26 -0700 Subject: [PATCH 01/19] Fix repeated Canvas link activation --- docs/components/canvas.md | 2 + .../Models/CanvasInteractionPolicy.swift | 23 ++++++ .../Features/Canvas/Views/CanvasView.swift | 23 ++++-- .../CanvasInteractionPolicyTests.swift | 77 +++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift create mode 100644 supacodeTests/CanvasInteractionPolicyTests.swift diff --git a/docs/components/canvas.md b/docs/components/canvas.md index e36da24f..6b472623 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -32,6 +32,8 @@ Canvas lets you **type a command once and send it to many agents at once**: Selection controls: - `⌘`-click a card body → toggle it in/out of the selection. +- `⌘`-click a terminal link → open it. Recognized links take priority over card + multi-selection, including when `⌘` is held across repeated clicks. - **Click (without `⌘`)** an already-selected card → make it the **primary** (the one you type into) without deselecting the rest. (`⌘`-clicking a selected card instead *removes* it from the selection.) diff --git a/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift new file mode 100644 index 00000000..21eb6575 --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift @@ -0,0 +1,23 @@ +enum CanvasInteractionPolicy { + static func linkActivationRequested( + hasHoveredLink: Bool, + isCommandModifierActive: Bool + ) -> Bool { + hasHoveredLink && isCommandModifierActive + } + + static func showsSelectionShield( + commandSelectionActive: Bool, + selectionModeActive: Bool, + broadcastFollower: Bool, + linkActivationRequested: Bool + ) -> Bool { + if linkActivationRequested { + return false + } + if commandSelectionActive || selectionModeActive { + return true + } + return broadcastFollower + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a11c31cb..815396f0 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -256,11 +256,22 @@ struct CanvasView: View { .onDisappear { deactivateCanvas() } } - func showsSelectionShield(for tabID: TerminalTabID) -> Bool { - if commandKeyObserver.isPressed { return true } - if selectionState.isSelecting { return true } - if selectionState.isBroadcasting, selectionState.primaryTabID != tabID { return true } - return false + func showsSelectionShield( + for tabID: TerminalTabID, + in tree: SplitTree + ) -> Bool { + let hasHoveredLink = tree.leaves().contains { + $0.bridge.state.mouseOverLink?.isEmpty == false + } + return CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: commandKeyObserver.isPressed, + selectionModeActive: selectionState.isSelecting, + broadcastFollower: selectionState.isBroadcasting && selectionState.primaryTabID != tabID, + linkActivationRequested: CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: hasHoveredLink, + isCommandModifierActive: NSEvent.modifierFlags.contains(.command) + ) + ) } // MARK: - Cards Layer @@ -358,7 +369,7 @@ struct CanvasView: View { isExpanded: isCardExpanded, expandHelp: expandHelp, canvasScale: isCardExpanded ? 1 : canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id), + showsSelectionShield: showsSelectionShield(for: tab.id, in: tree), onTap: { let cmdHeld = NSEvent.modifierFlags.contains(.command) if cmdHeld { diff --git a/supacodeTests/CanvasInteractionPolicyTests.swift b/supacodeTests/CanvasInteractionPolicyTests.swift new file mode 100644 index 00000000..ae6c3051 --- /dev/null +++ b/supacodeTests/CanvasInteractionPolicyTests.swift @@ -0,0 +1,77 @@ +import Testing + +@testable import supacode + +struct CanvasInteractionPolicyTests { + @Test func hoveredLinkTakesPriorityOverCommandSelection() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: true + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: true, + broadcastFollower: true, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested) + #expect(showsShield == false) + } + + @Test func commandSelectionStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func selectionModeStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func broadcastingOnlyShieldsFollowerCards() { + let primaryShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + let followerShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: true, + linkActivationRequested: false + ) + + #expect(primaryShowsShield == false) + #expect(followerShowsShield) + } + + @Test func hoveredLinkWithoutCommandStillUsesSelectionPolicy() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: false + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested == false) + #expect(showsShield) + } +} -- 2.51.2 From 8aec608586dbb4ba11505fe2ddef8e916fe344d8 Mon Sep 17 00:00:00 2001 From: Simon Heimlicher Date: Fri, 7 Aug 2026 11:52:51 +0200 Subject: [PATCH 02/19] Detect two live Claude states the typed profile still reports idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both survived the #684-#688 profile migration because no captured screen reaches the rules that carry them. `claude.elapsedStatus` and `claude.backgroundWork` are exercised only by inline synthetic screens written from the same assumptions as the predicates, so those screens cannot falsify them, and `claude.spinner` matches first on every captured working fixture. Captured live against Claude Code 2.1.224 with `prowl read --source detection`, sampling `prowl agents` at the same instant: ● Perusing… (1m 38s · ↓ 187 tokens) -> agents reported done ✻ Waiting for 1 background agent to finish -> agents reported done The status label is free text and is not always one word; the elapsed segment becomes several tokens past a minute, so a correctly shaped row detects for 59 seconds and then flips to idle for the rest of the turn. The background wait row carries a spinner glyph but no ellipsis, which hasSpinnerActivity requires, and the old marker matched the literal "agents done" that only the workflow variant of the row prints. The switcher block below the prompt looks like a better signal and is not one: a subagent that returns control while awaiting collection keeps its row with the elapsed frozen, so matching the row shape holds the pane at working after the work stopped. Observed live, and pinned by a test. Add AgentScreenRuleCoverageTests, which fails when a typed rule has no captured witness. It would have flagged both rules the day the corpus landed. One Codex rule is carried as explicit, documented debt. --- docs/components/agent-detection.md | 36 +++-- .../AgentDetection/ClaudeScreenProfile.swift | 74 +++++++-- .../AgentDetection/ScreenHeuristics.swift | 19 ++- .../AgentScreenRuleCoverageTests.swift | 61 ++++++++ .../ClaudeBackgroundAgentDetectionTests.swift | 143 ++++++++++++++++++ supacodeTests/ClaudeScreenProfileTests.swift | 6 + .../676-background-agent-wait.metadata.json | 16 ++ .../working/676-background-agent-wait.txt | 59 ++++++++ .../676-compound-elapsed-status.metadata.json | 16 ++ .../working/676-compound-elapsed-status.txt | 35 +++++ 10 files changed, 430 insertions(+), 35 deletions(-) create mode 100644 supacodeTests/AgentScreenRuleCoverageTests.swift create mode 100644 supacodeTests/ClaudeBackgroundAgentDetectionTests.swift create mode 100644 supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.224/working/676-background-agent-wait.metadata.json create mode 100644 supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.224/working/676-background-agent-wait.txt create mode 100644 supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.224/working/676-compound-elapsed-status.metadata.json create mode 100644 supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.224/working/676-compound-elapsed-status.txt diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index f32fbc92..8c29a9b4 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -48,10 +48,18 @@ at a `~/.grok/` install (so Cursor's own `agent` entrypoint stays Cursor). Other agent families keep their own patterns (including Oh My Pi's `Working… ⟦esc⟧` loader, braille frames, symbol cycles, Cursor's hexagons, Kimi's moon phases, etc.). - For Claude, a running **background workflow** keeps a status line *below* the - input box (e.g. `3/5 agents done · 7m 29s · ↓ 288.5k tokens`) after the turn has - ended; Prowl reads that footer as **Working**, so a churning workflow isn't - mistaken for idle. + Claude's live status row (`●