Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/components/agent-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ The sidebar worktree row spinner and `prowl list`'s `task.status` report
**background agents**, detected from the `✻ Waiting for … background agent …`
row even while the input box looks idle.

A **Blocked** agent is the exception to the spinner. Because it has stopped and
is waiting on you, the sidebar row shows a red attention icon instead of the
spinner — a spinner there would tell you to wait, which is backwards. The row
still counts as **running** for `prowl list`'s `task.status`, so the CLI
contract is unchanged; use `prowl agents` to tell blocked from working. A
worktree that is being created, archived, or deleted keeps its own spinner,
which takes precedence over the agent indicator.

It's a single coarse running/idle bit (it can't distinguish background agents
from a long command). For the agent's finer state use the
[Active Agents panel](active-agents.md) or [`prowl agents`](cli.md). Expect up to
Expand Down
8 changes: 8 additions & 0 deletions supacode/Domain/AgentDetection/PaneAgentState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ struct PaneAgentState: Equatable, Sendable {
guard detectedAgent != nil else { return false }
return displayState == .working || displayState == .blocked
}

/// The `.blocked` slice of `isBusy`: the agent has stopped and is waiting on
/// an answer (permission prompt, AskUserQuestion). Tracked separately so the
/// sidebar can distinguish "wait for it" from "it is waiting for you".
var isBlocked: Bool {
guard detectedAgent != nil else { return false }
return displayState == .blocked
}
}

struct AgentDetectionPresence: Equatable, Sendable {
Expand Down
22 changes: 18 additions & 4 deletions supacode/Features/Repositories/Views/WorktreeRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct WorktreeRow: View {
let isMainWorktree: Bool
let isLoading: Bool
let taskStatus: WorktreeTaskStatus?
let hasBlockedAgent: Bool
let isRunScriptRunning: Bool
let showsNotificationIndicator: Bool
let notifications: [WorktreeTerminalNotification]
Expand All @@ -41,6 +42,7 @@ struct WorktreeRow: View {
isMainWorktree: Bool,
isLoading: Bool,
taskStatus: WorktreeTaskStatus?,
hasBlockedAgent: Bool = false,
isRunScriptRunning: Bool,
showsNotificationIndicator: Bool,
notifications: [WorktreeTerminalNotification],
Expand All @@ -64,6 +66,7 @@ struct WorktreeRow: View {
self.isMainWorktree = isMainWorktree
self.isLoading = isLoading
self.taskStatus = taskStatus
self.hasBlockedAgent = hasBlockedAgent
self.isRunScriptRunning = isRunScriptRunning
self.showsNotificationIndicator = showsNotificationIndicator
self.notifications = notifications
Expand All @@ -78,7 +81,13 @@ struct WorktreeRow: View {
}

var body: some View {
let showsSpinner = isLoading || taskStatus == .running
// A blocked agent has stopped and is waiting on an answer, so it must not
// wear the running spinner — that affordance tells you to wait, which is
// the opposite of what a blocked agent needs. `isLoading` still wins: that
// spinner describes the row's own create/archive/delete work, not an agent.
let showsBlockedIndicator = hasBlockedAgent && !isLoading
let showsSpinner = isLoading || (taskStatus == .running && !showsBlockedIndicator)
let hidesBranchIcon = showsSpinner || showsBlockedIndicator
let branchIconName =
iconSystemName
?? (isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch"))
Expand Down Expand Up @@ -118,15 +127,20 @@ struct WorktreeRow: View {
.foregroundStyle(.orange)
.accessibilityLabel("Unread notifications")
}
.opacity(showsSpinner ? 0 : 1)
.opacity(hidesBranchIcon ? 0 : 1)
} else {
Image(systemName: branchIconName)
.font(.caption)
.foregroundStyle(.secondary)
.opacity(showsSpinner ? 0 : 1)
.opacity(hidesBranchIcon ? 0 : 1)
.accessibilityHidden(true)
}
if showsSpinner {
if showsBlockedIndicator {
Image(systemName: "exclamationmark.circle.fill")
.font(.caption)
.foregroundStyle(.red)
.accessibilityLabel("Agent is waiting for your input")
} else if showsSpinner {
ProgressView()
.controlSize(.small)
}
Expand Down
2 changes: 2 additions & 0 deletions supacode/Features/Repositories/Views/WorktreeRowsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ struct WorktreeRowsView: View {
let isSelected = selectedWorktreeIDs.contains(row.id)
let showsContextMenuHighlight = contextMenuHighlightedWorktreeID == row.id && !isSelected
let taskStatus = terminalManager.taskStatus(for: row.id)
let hasBlockedAgent = terminalManager.hasBlockedAgent(for: row.id)
let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id)
let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty
return WorktreeRow(
Expand All @@ -392,6 +393,7 @@ struct WorktreeRowsView: View {
isMainWorktree: row.isMainWorktree,
isLoading: row.isPending || row.isArchiving || row.isDeleting,
taskStatus: taskStatus,
hasBlockedAgent: hasBlockedAgent,
isRunScriptRunning: isRunScriptRunning,
showsNotificationIndicator: config.showsNotificationIndicator,
notifications: config.notifications,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,13 @@ final class WorktreeTerminalManager {
states[worktreeID]?.taskStatus
}

/// Whether the worktree holds an agent awaiting an answer. Paired with
/// `taskStatus(for:)` at the sidebar row so a blocked agent reads as needing
/// input rather than as work in progress.
func hasBlockedAgent(for worktreeID: Worktree.ID) -> Bool {
states[worktreeID]?.hasBlockedAgent == true
}

func isRunScriptRunning(for worktreeID: Worktree.ID) -> Bool {
states[worktreeID]?.isRunScriptRunning == true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,15 @@ extension WorktreeTerminalState {
func updateTabAgentBusyState(for tabId: TerminalTabID) {
let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? []
let isBusy = surfaceIDs.contains { surfaceAgentStates[$0]?.isBusy == true }
let isBlocked = surfaceIDs.contains { surfaceAgentStates[$0]?.isBlocked == true }
// Blocked is tracked even when `isBusy` is unchanged: working → blocked
// leaves the aggregate busy, and that transition is exactly when the
// sidebar must swap the spinner for the attention affordance. The view
// observes `tabAgentBlockedById` directly, so no task-status event is
// needed to redraw it.
if (tabAgentBlockedById[tabId] ?? false) != isBlocked {
tabAgentBlockedById[tabId] = isBlocked
}
guard (tabAgentBusyById[tabId] ?? false) != isBusy else { return }
tabAgentBusyById[tabId] = isBusy
emitTaskStatusIfChanged()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ extension WorktreeTerminalState {
cleanupAllAgentDetectionState()
tabIsRunningById.removeAll()
tabAgentBusyById.removeAll()
tabAgentBlockedById.removeAll()
boundDirectoryTabIDs.removeAll()
autoCloseSurfaceIds.removeAll()
pendingCustomCommands.removeAll()
Expand Down Expand Up @@ -660,6 +661,7 @@ extension WorktreeTerminalState {
focusedSurfaceIdByTab.removeValue(forKey: tabId)
tabIsRunningById.removeValue(forKey: tabId)
tabAgentBusyById.removeValue(forKey: tabId)
tabAgentBlockedById.removeValue(forKey: tabId)
}

func tabID(containing surfaceId: UUID) -> TerminalTabID? {
Expand Down
15 changes: 15 additions & 0 deletions supacode/Features/Terminal/Models/WorktreeTerminalState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ final class WorktreeTerminalState {
/// sidebar spinner and `prowl list` reflect agent activity, not just OSC 9;4
/// command progress (which Claude Code does not emit while it works).
var tabAgentBusyById: [TerminalTabID: Bool] = [:]
/// Per-tab aggregate of the `.blocked` slice of `tabAgentBusyById`: `true`
/// when a surface in the tab holds an agent awaiting an answer (permission
/// prompt, AskUserQuestion). Kept separate rather than folded into
/// `taskStatus` because the two states call for opposite affordances — a
/// spinner tells you to wait, a blocked agent is waiting on you — and
/// `WorktreeTaskStatus` has no case to carry the difference.
var tabAgentBlockedById: [TerminalTabID: Bool] = [:]
var boundDirectoryTabIDs: [String: TerminalTabID] = [:]
var surfaceRunningStartedAtById: [UUID: Date] = [:]
var lastDefocusedAt: Date?
Expand Down Expand Up @@ -409,6 +416,14 @@ final class WorktreeTerminalState {
return (hasRunningCommand || hasBusyAgent) ? .running : .idle
}

/// Whether any tab holds an agent awaiting an answer. Read alongside
/// `taskStatus` by the sidebar so a blocked worktree shows an attention
/// affordance instead of the running spinner. `taskStatus` itself is
/// unchanged, so `prowl list` keeps reporting `running` for these.
var hasBlockedAgent: Bool {
tabAgentBlockedById.values.contains(true)
}

var isRunScriptRunning: Bool {
runScriptTabId != nil
}
Expand Down
50 changes: 50 additions & 0 deletions supacodeTests/WorktreeTerminalManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,56 @@ struct WorktreeTerminalManagerTests {
#expect(state.taskStatus == .idle)
}

@MainActor
@Test func blockedAgentIsTrackedSeparatelyFromBusy() throws {
let manager = WorktreeTerminalManager(runtime: GhosttyRuntime())
let worktree = makeWorktree()
let state = manager.state(for: worktree)

let tabId = try #require(state.createTab())
let surfaceId = try #require(state.focusedSurfaceId(in: tabId))

// Working: busy, but nothing is waiting on the user.
state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working)
state.updateTabAgentBusyState(for: tabId)
#expect(state.taskStatus == .running)
#expect(state.hasBlockedAgent == false)

// working → blocked leaves the busy aggregate unchanged, so the blocked
// flag is the only thing that can tell the sidebar to stop spinning.
state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .blocked)
state.updateTabAgentBusyState(for: tabId)
#expect(state.taskStatus == .running)
#expect(state.hasBlockedAgent)
#expect(manager.hasBlockedAgent(for: worktree.id))

// Answered: back to working, attention affordance clears.
state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working)
state.updateTabAgentBusyState(for: tabId)
#expect(state.hasBlockedAgent == false)

state.closeAllSurfaces()
#expect(state.tabAgentBlockedById.isEmpty)
#expect(state.hasBlockedAgent == false)
}

@MainActor
@Test func blockedFlagIgnoresPanesWithoutADetectedAgent() throws {
let manager = WorktreeTerminalManager(runtime: GhosttyRuntime())
let worktree = makeWorktree()
let state = manager.state(for: worktree)

let tabId = try #require(state.createTab())
let surfaceId = try #require(state.focusedSurfaceId(in: tabId))

// A bare shell can carry a stale raw state; without a detected agent it
// must not light up the sidebar.
state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: nil, state: .blocked)
state.updateTabAgentBusyState(for: tabId)
#expect(state.hasBlockedAgent == false)
#expect(state.taskStatus == .idle)
}

private func nextEvent(
_ stream: AsyncStream<TerminalClient.Event>,
matching predicate: (TerminalClient.Event) -> Bool
Expand Down
Loading