diff --git a/README.md b/README.md index 6f7d872..5416b15 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Shellraiser is a macOS terminal workspace app built with SwiftUI and GhosttyKit. - Surface tabs inside each pane for managing multiple sessions - Command palette and keyboard shortcuts for workspace and pane actions - Completion tracking and jump-to-next-completed-session workflow +- Needs Your Input detection — distinguishes an agent blocked on a permission prompt from one that has actually finished, across Claude Code, Codex, and Copilot CLI; shows an amber sidebar indicator, sends a distinct "Needs Your Input" notification, and Cmd+Shift+I jumps to the next session awaiting input - AppleScript support for creating workspaces, splitting terminals, focusing surfaces, sending keys, and inputting text - macOS notifications — native notification when an agent turn completes in an unfocused surface; click to jump to it - Git branch display — sidebar shows current branch name and a linked-worktree indicator per workspace diff --git a/Sources/Shellraiser/App/ShellraiserApp.swift b/Sources/Shellraiser/App/ShellraiserApp.swift index cf6713f..6fbb325 100644 --- a/Sources/Shellraiser/App/ShellraiserApp.swift +++ b/Sources/Shellraiser/App/ShellraiserApp.swift @@ -222,6 +222,12 @@ struct WorkspaceCommands: Commands { } .keyboardShortcut("u", modifiers: [.command, .shift]) .disabled(!manager.hasPendingCompletions) + + Button("Jump to Next Session Awaiting Input") { + manager.jumpToNextSessionAwaitingInput() + } + .keyboardShortcut("i", modifiers: [.command, .shift]) + .disabled(!manager.hasAwaitingInput) } CommandMenu("Pane") { diff --git a/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceListView.swift b/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceListView.swift index 1feab66..917f593 100644 --- a/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceListView.swift +++ b/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceListView.swift @@ -44,6 +44,7 @@ struct WorkspaceListView: View { focusedGitState: manager.focusedGitState(workspaceId: workspace.id), isWorking: manager.isWorkspaceWorking(workspaceId: workspace.id), pendingCount: manager.pendingCompletionCount(workspaceId: workspace.id), + awaitingCount: manager.awaitingInputCount(workspaceId: workspace.id), onSelect: { withAnimation(.spring(response: 0.32, dampingFraction: 0.84)) { manager.selectWorkspace(workspace.id) diff --git a/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceSidebarRow.swift b/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceSidebarRow.swift index 450e44e..d91185e 100644 --- a/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceSidebarRow.swift +++ b/Sources/Shellraiser/Features/WorkspaceSidebar/WorkspaceSidebarRow.swift @@ -8,6 +8,7 @@ struct WorkspaceSidebarRow: View { let focusedGitState: ResolvedGitState? let isWorking: Bool let pendingCount: Int + let awaitingCount: Int let onSelect: () -> Void let onRename: () -> Void let onDelete: () -> Void @@ -19,7 +20,7 @@ struct WorkspaceSidebarRow: View { /// Returns whether the row should render a dedicated status line. private var showsStatusRow: Bool { - pendingCount > 0 + pendingCount > 0 || awaitingCount > 0 } var body: some View { @@ -106,9 +107,13 @@ struct WorkspaceSidebarRow: View { } } - /// Renders workspace-level working and pending-completion indicators. + /// Renders workspace-level working, awaiting-input, and pending-completion indicators. private var statusRow: some View { HStack(spacing: 10) { + if awaitingCount > 0 { + WorkspaceAwaitingInputIndicator(count: awaitingCount) + } + if pendingCount > 0 { WorkspacePendingIndicator(count: pendingCount) } @@ -218,6 +223,32 @@ private struct WorkspaceWorkingIndicator: View { } } +/// Pulsing indicator shown while a workspace has surfaces waiting for user input or approval. +private struct WorkspaceAwaitingInputIndicator: View { + let count: Int + + @ViewBuilder + var body: some View { + HStack(spacing: 4) { + if #available(macOS 15.0, *) { + Image(systemName: "exclamationmark.bubble.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.orange) + .symbolEffect(.pulse, options: .repeat(.continuous)) + } else { + Image(systemName: "exclamationmark.bubble.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.orange) + } + + Text("\(count)") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(AppTheme.textPrimary) + } + .accessibilityLabel("Workspace has \(count) session\(count == 1 ? "" : "s") waiting for input") + } +} + /// Animated bell shown while a workspace owns queued completions. private struct WorkspacePendingIndicator: View { let count: Int diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionNotificationManager.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionNotificationManager.swift index 6e87c48..372f4a9 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionNotificationManager.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionNotificationManager.swift @@ -20,8 +20,25 @@ final class AgentCompletionNotificationManager: NSObject, AgentCompletionNotific target: PendingCompletionTarget, workspaceName: String ) { + scheduleNotification(target: target, workspaceName: workspaceName, kind: .finished) + } + + /// Schedules a user-visible notification of the given kind. + func scheduleNotification( + target: PendingCompletionTarget, + workspaceName: String, + kind: AgentNotificationKind + ) { + let title: String + switch kind { + case .finished: + title = "\(target.surface.agentType.displayName) Finished Responding" + case .waitingForInput: + title = "\(target.surface.agentType.displayName) Needs Your Input" + } + let content = UNMutableNotificationContent() - content.title = "\(target.surface.agentType.displayName) Finished Responding" + content.title = title content.subtitle = workspaceName content.body = target.surface.title content.sound = .default @@ -30,20 +47,36 @@ final class AgentCompletionNotificationManager: NSObject, AgentCompletionNotific "workspaceId": target.workspaceId.uuidString ] - let identifier = "completion-\(target.sequence)-\(target.surface.id.uuidString)" + let identifier = notificationIdentifier(for: target, kind: kind) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + // Recorded synchronously (we're already on the main actor here) so a + // back-to-back duplicate event sees the identifier immediately instead of + // racing UNUserNotificationCenter's asynchronous completion handler. + notificationIdsBySurfaceId[target.surface.id, default: []].insert(identifier) + CompletionDebugLogger.log( + "scheduled notification id=\(identifier) surface=\(target.surface.id.uuidString)" + ) + center.add(request) { [weak self] error in - guard error == nil else { return } + guard error != nil else { return } Task { @MainActor in - CompletionDebugLogger.log( - "scheduled notification id=\(identifier) surface=\(target.surface.id.uuidString)" - ) - self?.notificationIdsBySurfaceId[target.surface.id, default: []].insert(identifier) + self?.notificationIdsBySurfaceId[target.surface.id]?.remove(identifier) } } } + /// Returns a stable identifier for waiting-for-input (one live banner per surface) + /// and a sequence-scoped identifier for completion notifications. + private func notificationIdentifier(for target: PendingCompletionTarget, kind: AgentNotificationKind) -> String { + switch kind { + case .finished: + return "completion-\(target.sequence)-\(target.surface.id.uuidString)" + case .waitingForInput: + return "waiting-for-input-\(target.surface.id.uuidString)" + } + } + /// Removes any delivered notifications associated with a handled or closed surface. func removeNotifications(for surfaceId: UUID) { guard let identifiers = notificationIdsBySurfaceId.removeValue(forKey: surfaceId), !identifiers.isEmpty else { diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift index 4c93454..4496bf5 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift @@ -216,7 +216,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { payload="" session_id="" case "$phase" in - started|completed|session|exited|hook-session) + started|completed|session|exited|hook-session|waiting-for-input|notification) ;; *) exit 0 @@ -243,6 +243,23 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { payload="$session_id" phase="session" ;; + copilot:notification) + hook_payload="$(cat 2>/dev/null || true)" + compact_payload="$(printf '%s' "$hook_payload" | tr -d '\n')" + notification_type="$(printf '%s' "$compact_payload" | sed -n 's/.*"notification_type"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')" + case "$notification_type" in + shell_completed|shell_detached_completed|agent_completed|agent_idle) + exit 0 + ;; + *) + # Empty (stdin unavailable) or permission_prompt/elicitation_dialog: + # fail open to waiting-for-input. The hook's "matcher" already + # restricts invocation to permission_prompt|elicitation_dialog, so + # this classification is defence-in-depth, not the primary filter. + phase="waiting-for-input" + ;; + esac + ;; esac if [ "$phase" = "session" ] && [ -z "$session_id" ]; then @@ -349,7 +366,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { "hooks": [ { "type": "command", - "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" completed" + "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" waiting-for-input" } ] } @@ -360,7 +377,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { "hooks": [ { "type": "command", - "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" completed" + "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" waiting-for-input" } ] }, @@ -369,7 +386,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { "hooks": [ { "type": "command", - "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" completed" + "command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" waiting-for-input" } ] } @@ -423,8 +440,9 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { -c "hooks.SessionStart=[{hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" hook-session\"}]}]" \ -c "hooks.UserPromptSubmit=[{hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" started\"}]}]" \ -c "hooks.PreToolUse=[{matcher=\"*\",hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" started\"}]}]" \ - -c "hooks.PermissionRequest=[{matcher=\"*\",hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" completed\"}]}]" \ + -c "hooks.PermissionRequest=[{matcher=\"*\",hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" waiting-for-input\"}]}]" \ -c "hooks.Stop=[{hooks=[{type=\"command\",command=\"\\\"$helper\\\" codex \\\"$surface\\\" completed\"}]}]" \ + --dangerously-bypass-hook-trust \ "$@" status=$? set -e @@ -529,7 +547,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { "userPromptSubmitted": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" started; fi", "timeoutSec": 5}], "preToolUse": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" started; fi", "timeoutSec": 5}], "agentStop": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" completed; fi", "timeoutSec": 5}], - "notification": [{"type": "command", "matcher": "permission_prompt|elicitation_dialog", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" completed; fi", "timeoutSec": 5}], + "notification": [{"type": "command", "matcher": "permission_prompt|elicitation_dialog", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" notification > /dev/null; fi", "timeoutSec": 5}], "sessionEnd": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" exited; fi", "timeoutSec": 5}] } } @@ -649,4 +667,6 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_REAL_COPILOT SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH """# } + } + diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeInterfaces.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeInterfaces.swift index 866e6b2..3282610 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeInterfaces.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeInterfaces.swift @@ -16,6 +16,14 @@ protocol AgentActivityEventMonitoring: AnyObject { var onEvent: ((AgentActivityEvent) -> Void)? { get set } } +/// Semantic kind of an agent-status notification. +enum AgentNotificationKind { + /// The agent completed its turn normally. + case finished + /// The agent is blocked waiting for the user to approve a permission or answer a prompt. + case waitingForInput +} + /// Notification manager contract consumed by the workspace manager. protocol AgentCompletionNotificationManaging: AnyObject { /// Callback fired when the user activates a completion notification. @@ -24,6 +32,9 @@ protocol AgentCompletionNotificationManaging: AnyObject { /// Schedules a user-visible completion notification. func scheduleNotification(target: PendingCompletionTarget, workspaceName: String) + /// Schedules a user-visible notification of the given kind. + func scheduleNotification(target: PendingCompletionTarget, workspaceName: String, kind: AgentNotificationKind) + /// Removes pending and delivered notifications for a surface. func removeNotifications(for surfaceId: UUID) } diff --git a/Sources/Shellraiser/Infrastructure/Agents/CompletionModels.swift b/Sources/Shellraiser/Infrastructure/Agents/CompletionModels.swift index fd5a366..f9bd5c7 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/CompletionModels.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/CompletionModels.swift @@ -23,6 +23,7 @@ enum AgentActivityPhase: String { case completed case session case exited + case waitingForInput = "waiting-for-input" } /// Parsed activity event emitted by managed Claude/Codex wrappers. diff --git a/Sources/Shellraiser/Models/PaneNodeModel+Operations.swift b/Sources/Shellraiser/Models/PaneNodeModel+Operations.swift index 52e17b9..26da43f 100644 --- a/Sources/Shellraiser/Models/PaneNodeModel+Operations.swift +++ b/Sources/Shellraiser/Models/PaneNodeModel+Operations.swift @@ -361,6 +361,16 @@ extension PaneNodeModel { } } + /// Returns the surface model for a given identifier anywhere in the pane tree. + func surface(id surfaceId: UUID) -> SurfaceModel? { + switch self { + case .leaf(let leaf): + return leaf.surfaces.first { $0.id == surfaceId } + case .split(let split): + return split.first.surface(id: surfaceId) ?? split.second.surface(id: surfaceId) + } + } + /// Returns pending completion surfaces along with their owning panes. func pendingSurfaceSnapshots() -> [(paneId: UUID, surface: SurfaceModel)] { switch self { diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+CommandPalette.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+CommandPalette.swift index 19e7f86..fa86330 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+CommandPalette.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+CommandPalette.swift @@ -74,6 +74,20 @@ extension WorkspaceManager { } ) + items.append( + CommandPaletteItem( + id: "workspace.next-awaiting-input", + title: "Jump To Next Session Awaiting Input", + category: "Workspace", + systemImage: "exclamationmark.bubble.fill", + shortcut: "cmd-shift-i", + isEnabled: hasAwaitingInput, + keywords: ["approval", "permission", "input", "waiting", "blocked", "needs", "queue", "next"] + ) { + self.jumpToNextSessionAwaitingInput() + } + ) + items.append(contentsOf: paneCommandPaletteItems()) items.append(contentsOf: terminalCommandPaletteItems()) diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift index e57de2c..e7fac95 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift @@ -10,6 +10,40 @@ extension WorkspaceManager { return workspace.rootPane.allSurfaceIds().contains { busySurfaceIds.contains($0) } } + /// Returns whether any live surface in a workspace is blocked waiting for user input. + func isWorkspaceAwaitingInput(workspaceId: UUID) -> Bool { + guard let workspace = workspace(id: workspaceId) else { return false } + + return workspace.rootPane.allSurfaceIds().contains { awaitingInputSurfaceIds.contains($0) } + } + + /// Returns the number of live surfaces in a workspace currently waiting for user input. + func awaitingInputCount(workspaceId: UUID) -> Int { + guard let workspace = workspace(id: workspaceId) else { return 0 } + + return workspace.rootPane.allSurfaceIds().filter { awaitingInputSurfaceIds.contains($0) }.count + } + + /// Returns whether any surface across the app is currently waiting for user input. + var hasAwaitingInput: Bool { + !awaitingInputSurfaceIds.isEmpty + } + + /// Focuses the first surface currently awaiting user input. + func jumpToNextSessionAwaitingInput() { + for workspace in workspaces { + for surfaceId in workspace.rootPane.allSurfaceIds() { + if awaitingInputSurfaceIds.contains(surfaceId) { + CompletionDebugLogger.log( + "focus awaiting-input surface=\(surfaceId.uuidString)" + ) + focusCompletionSurface(surfaceId) + return + } + } + } + } + /// Enqueues a newly completed agent turn for notifications and FIFO navigation. func enqueueCompletion( workspaceId: UUID, @@ -124,10 +158,13 @@ extension WorkspaceManager { persistence: persistence ) completionNotifications.removeNotifications(for: event.surfaceId) + clearSurfaceAwaitingInput(event.surfaceId) updateDockBadge() markSurfaceBusy(event.surfaceId) case .completed: clearBusySurface(event.surfaceId) + clearSurfaceAwaitingInput(event.surfaceId) + completionNotifications.removeNotifications(for: event.surfaceId) enqueueCompletion( workspaceId: target.workspaceId, surfaceId: event.surfaceId, @@ -135,6 +172,35 @@ extension WorkspaceManager { timestamp: event.timestamp, payload: event.payload ) + case .waitingForInput: + clearBusySurface(event.surfaceId) + let wasAlreadyAwaiting = awaitingInputSurfaceIds.contains(event.surfaceId) + markSurfaceAwaitingInput(event.surfaceId) + guard !wasAlreadyAwaiting else { + CompletionDebugLogger.log( + "suppress duplicate waiting-for-input surface=\(event.surfaceId.uuidString)" + ) + return + } + completionNotifications.removeNotifications(for: event.surfaceId) + if let surface = workspace(id: target.workspaceId)?.rootPane.surface(id: event.surfaceId), + let workspace = workspace(id: target.workspaceId), + shouldScheduleCompletionNotification(for: event.surfaceId) { + let fakeTarget = PendingCompletionTarget( + workspaceId: target.workspaceId, + paneId: target.paneId, + surface: surface, + sequence: nextPendingCompletionSequence + ) + completionNotifications.scheduleNotification( + target: fakeTarget, + workspaceName: workspace.name, + kind: .waitingForInput + ) + CompletionDebugLogger.log( + "waiting-for-input notification workspace=\(target.workspaceId.uuidString) surface=\(event.surfaceId.uuidString)" + ) + } case .session: let identity = parsedSessionIdentity(from: event) surfaceManager.setSessionIdentity( @@ -148,6 +214,9 @@ extension WorkspaceManager { ) case .exited: clearBusySurface(event.surfaceId) + clearSurfaceAwaitingInput(event.surfaceId) + clearLiveCodexSessionSurface(event.surfaceId) + completionNotifications.removeNotifications(for: event.surfaceId) guard !isTerminating else { return } surfaceManager.setResumeEligibility( workspaceId: target.workspaceId, @@ -336,4 +405,33 @@ extension WorkspaceManager { busySurfaceIds.subtract(surfaceIds) } + /// Marks a surface as blocked waiting for user input or approval. + func markSurfaceAwaitingInput(_ surfaceId: UUID) { + awaitingInputSurfaceIds.insert(surfaceId) + } + + /// Clears waiting-for-input state for one surface. + func clearSurfaceAwaitingInput(_ surfaceId: UUID) { + awaitingInputSurfaceIds.remove(surfaceId) + } + + /// Clears waiting-for-input state for a group of surfaces. + func clearSurfacesAwaitingInput(_ surfaceIds: S) where S.Element == UUID { + awaitingInputSurfaceIds.subtract(surfaceIds) + } + + /// Records that the runtime discovered a live Codex session for one surface. + func markLiveCodexSessionSurface(_ surfaceId: UUID) { + liveCodexSessionSurfaceIds.insert(surfaceId) + } + + /// Clears one runtime-discovered Codex session gate. + func clearLiveCodexSessionSurface(_ surfaceId: UUID) { + liveCodexSessionSurfaceIds.remove(surfaceId) + } + + /// Clears runtime-discovered Codex session gates for multiple surfaces. + func clearLiveCodexSessionSurfaces(_ surfaceIds: S) where S.Element == UUID { + liveCodexSessionSurfaceIds.subtract(surfaceIds) + } } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Shortcuts.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Shortcuts.swift index 00d81f3..d368894 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Shortcuts.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Shortcuts.swift @@ -88,6 +88,11 @@ extension WorkspaceManager { return true } + if key == "i", hasShift { + jumpToNextSessionAwaitingInput() + return true + } + if key == "w", hasShift { requestDeleteSelectedWorkspace() return true diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index 01af673..2e5099a 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -42,6 +42,8 @@ extension WorkspaceManager { GhosttyRuntime.shared.endSearch(surfaceId: surfaceId) GhosttyRuntime.shared.releaseSurface(surfaceId: surfaceId) clearBusySurface(surfaceId) + clearSurfaceAwaitingInput(surfaceId) + clearLiveCodexSessionSurface(surfaceId) clearGitBranch(surfaceId: surfaceId) clearProgressReport(surfaceId: surfaceId) diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift index d2b7175..792c5ed 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift @@ -65,6 +65,8 @@ extension WorkspaceManager { persistence: persistence ) clearBusySurfaces(releasedSurfaceIds) + clearSurfacesAwaitingInput(releasedSurfaceIds) + clearLiveCodexSessionSurfaces(releasedSurfaceIds) releasedSurfaceIds.forEach { completionNotifications.removeNotifications(for: $0) GhosttyRuntime.shared.releaseSurface(surfaceId: $0) diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift index 79219a3..d8964e0 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -74,6 +74,8 @@ final class WorkspaceManager: ObservableObject { @Published var pendingWorkspaceRename: WorkspaceRenameRequest? @Published var gitStatesBySurfaceId: [UUID: ResolvedGitState] = [:] @Published var busySurfaceIds: Set = [] + @Published var awaitingInputSurfaceIds: Set = [] + @Published var liveCodexSessionSurfaceIds: Set = [] @Published var progressBySurfaceId: [UUID: SurfaceProgressReport] = [:] var progressClearTimers: [UUID: Timer] = [:] /// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks. diff --git a/Tests/ShellraiserTests/AgentCompletionEventTests.swift b/Tests/ShellraiserTests/AgentCompletionEventTests.swift index b7a4ad8..eb4ed33 100644 --- a/Tests/ShellraiserTests/AgentCompletionEventTests.swift +++ b/Tests/ShellraiserTests/AgentCompletionEventTests.swift @@ -68,4 +68,30 @@ final class AgentCompletionEventTests: XCTestCase { XCTAssertNil(AgentActivityEvent.parse("2026-03-08T20:00:00Z\tcodex\tnot-a-uuid\tcompleted\t")) XCTAssertNil(AgentActivityEvent.parse("2026-03-08T20:00:00Z\tcodex\t00000000-0000-0000-0000-000000001401\tunknown\t")) } + + /// Verifies waiting-for-input events decode to the dedicated phase. + func testParseDecodesWaitingForInputEvent() { + let surfaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001404")! + let line = "2026-03-08T20:07:00Z\tclaudeCode\t\(surfaceId.uuidString)\twaiting-for-input\t" + + let event = AgentActivityEvent.parse(line) + + XCTAssertEqual(event?.agentType, .claudeCode) + XCTAssertEqual(event?.surfaceId, surfaceId) + XCTAssertEqual(event?.phase, .waitingForInput) + } + + /// Verifies Copilot waiting-for-input events (emitted by the reclassified + /// `notification` hook for `permission_prompt`/`elicitation_dialog`) decode to + /// the dedicated phase, not `completed`. + func testParseDecodesCopilotWaitingForInputEvent() { + let surfaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001405")! + let line = "2026-03-08T20:08:00Z\tcopilot\t\(surfaceId.uuidString)\twaiting-for-input\t" + + let event = AgentActivityEvent.parse(line) + + XCTAssertEqual(event?.agentType, .copilot) + XCTAssertEqual(event?.surfaceId, surfaceId) + XCTAssertEqual(event?.phase, .waitingForInput) + } } diff --git a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift index c969b51..d7ab73c 100644 --- a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift +++ b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift @@ -73,6 +73,8 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertTrue(wrapperContents.contains("\"matcher\": \"elicitation_dialog\"")) XCTAssertTrue(wrapperContents.contains("claudeCode \"$surface\" exited")) XCTAssertFalse(wrapperContents.contains("\"SubagentStop\"")) + // PermissionRequest and Notification hooks must emit waiting-for-input, not completed. + XCTAssertTrue(wrapperContents.contains("waiting-for-input")) } /// Verifies the helper script only matches fully qualified managed runtime phases. @@ -121,7 +123,7 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertFalse(claudeWrapperContents.contains("--session-id")) XCTAssertTrue(claudeWrapperContents.contains("claudeCode \"$surface\" exited")) - // Codex wrapper: native hooks replace polling heuristics + // Codex wrapper: uses native inline hooks (same as 0e76333), PermissionRequest → waiting-for-input XCTAssertTrue(codexWrapperContents.contains("\"$real\" --help 2>&1 | /usr/bin/grep -Fq -- \"--dangerously-bypass-hook-trust\"")) XCTAssertFalse(codexWrapperContents.contains(" --dangerously-bypass-hook-trust \\")) XCTAssertTrue(codexWrapperContents.contains("hooks.SessionStart")) @@ -131,6 +133,7 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertTrue(codexWrapperContents.contains("hooks.Stop")) XCTAssertTrue(codexWrapperContents.contains(#"command=\"\\\"$helper\\\" codex \\\"$surface\\\" hook-session\""#)) XCTAssertTrue(codexWrapperContents.contains(#"command=\"\\\"$helper\\\" codex \\\"$surface\\\" started\""#)) + XCTAssertTrue(codexWrapperContents.contains(#"command=\"\\\"$helper\\\" codex \\\"$surface\\\" waiting-for-input\""#)) XCTAssertTrue(codexWrapperContents.contains(#"command=\"\\\"$helper\\\" codex \\\"$surface\\\" completed\""#)) XCTAssertTrue(codexWrapperContents.contains(#""$helper" codex "$surface" exited"#)) XCTAssertTrue(codexWrapperContents.contains("lookup_path=\"${SHELLRAISER_ORIGINAL_PATH:-${PATH:-}}\"")) @@ -198,4 +201,37 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertFalse(helperContents.contains("/usr/bin/python3")) XCTAssertTrue(helperContents.contains("phase=\"session\"")) } + + /// Verifies Copilot's notification hook signals waiting-for-input, not completed. + /// + /// A blocked Copilot permission prompt must not be reported as a finished turn. + /// The generated hook dispatches a `notification` phase (with stdout suppressed, + /// since Copilot parses hook stdout as JSON and would otherwise inject any + /// `additionalContext` into the session), and the helper reclassifies it to + /// `waiting-for-input` by parsing the payload's `notification_type` field, + /// failing open (trusting the hook's own matcher) when stdin is unavailable. + func testPrepareRuntimeSupportWritesCopilotNotificationHookAsWaitingForInput() throws { + let bridge = try makeBridge() + let helperURL = bridge.binDirectory.appendingPathComponent("shellraiser-agent-complete") + let copilotHookManagerURL = bridge.binDirectory.appendingPathComponent("shellraiser-copilot-hooks") + + bridge.prepareRuntimeSupport() + + let helperContents = try String(contentsOf: helperURL, encoding: .utf8) + let copilotHookManagerContents = try String(contentsOf: copilotHookManagerURL, encoding: .utf8) + + // The notification hook must preserve its matcher and dispatch "notification", + // not hardcode "completed", and must not leak anything onto stdout. + XCTAssertTrue(copilotHookManagerContents.contains("permission_prompt|elicitation_dialog")) + XCTAssertTrue(copilotHookManagerContents.contains(#"copilot \"$SHELLRAISER_SURFACE_ID\" notification > /dev/null"#)) + + // The helper must classify the notification payload's notification_type field + // and fail open to waiting-for-input when it can't be determined. + XCTAssertTrue(helperContents.contains("copilot:notification)")) + XCTAssertTrue(helperContents.contains("\"notification_type\"")) + XCTAssertTrue(helperContents.contains("shell_completed|shell_detached_completed|agent_completed|agent_idle)")) + XCTAssertTrue(helperContents.contains("phase=\"waiting-for-input\"")) + XCTAssertTrue(helperContents.contains("started|completed|session|exited|hook-session|waiting-for-input|notification)")) + } } + diff --git a/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift b/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift index 5e58dd1..384948a 100644 --- a/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift +++ b/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift @@ -753,4 +753,453 @@ final class WorkspaceManagerCompletionTests: WorkspaceTestCase { ) ) } + + // MARK: - Waiting-for-input state + + /// Verifies waiting-for-input events populate awaitingInputSurfaceIds without enqueuing a completion. + func testWaitingForInputEventMarksAwaitingWithoutEnqueuingCompletion() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001180")!, + title: "Waiting Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001181")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001182")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Waiting Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_000), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + ) + + XCTAssertTrue(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertTrue(manager.isWorkspaceAwaitingInput(workspaceId: workspaceId)) + XCTAssertEqual(manager.awaitingInputCount(workspaceId: workspaceId), 1) + XCTAssertTrue(manager.hasAwaitingInput) + XCTAssertTrue(manager.pendingCompletionTargets().isEmpty, "Should not enqueue a completion") + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + XCTAssertEqual(notifications.scheduledNotifications.count, 1) + XCTAssertEqual(notifications.scheduledNotifications.first?.kind, .waitingForInput) + XCTAssertEqual(notifications.scheduledNotifications.first?.workspaceName, "Waiting Workspace") + } + + /// Verifies a started event clears waiting-for-input state set by a preceding permission prompt. + func testStartedEventClearsAwaitingInputState() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001183")!, + title: "Resumed Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001184")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001185")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_010), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .started, + payload: "" + ) + ) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertFalse(manager.isWorkspaceAwaitingInput(workspaceId: workspaceId)) + XCTAssertFalse(manager.hasAwaitingInput) + } + + /// Verifies completed event clears waiting-for-input state as well as marking completion. + func testCompletedEventClearsAwaitingInputState() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001186")!, + title: "Completing Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001187")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001188")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_020), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .completed, + payload: "" + ) + ) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertFalse(manager.hasAwaitingInput) + } + + /// Verifies exited event clears waiting-for-input state. + func testExitedEventClearsAwaitingInputState() { + let eventMonitor = MockAgentActivityEventMonitor() + let persistence = InMemoryWorkspacePersistence() + let manager = makeWorkspaceManager(persistence: persistence, eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001189")!, + title: "Exiting Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001190")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001191")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_030), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .exited, + payload: "" + ) + ) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertFalse(manager.hasAwaitingInput) + } + + /// Verifies a Copilot waiting-for-input event (emitted by the reclassified + /// `notification` hook for a genuine `permission_prompt`) marks the surface as + /// awaiting input, schedules a Copilot-specific notification, and does not + /// enqueue a completion — mirroring Claude Code/Codex behaviour for any `AgentType`. + func testCopilotWaitingForInputEventMarksAwaitingWithoutEnqueuingCompletion() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001192")!, + title: "Copilot Waiting Surface", + agentType: .copilot + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001193")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001194")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Copilot Waiting Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_040), + agentType: .copilot, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + ) + + XCTAssertTrue(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertTrue(manager.isWorkspaceAwaitingInput(workspaceId: workspaceId)) + XCTAssertTrue(manager.hasAwaitingInput) + XCTAssertTrue(manager.pendingCompletionTargets().isEmpty, "Should not enqueue a completion") + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + XCTAssertEqual(notifications.scheduledNotifications.count, 1) + XCTAssertEqual(notifications.scheduledNotifications.first?.kind, .waitingForInput) + XCTAssertEqual(notifications.scheduledNotifications.first?.workspaceName, "Copilot Waiting Workspace") + } + + /// Verifies two rapid waiting-for-input events for the same surface (e.g. Claude Code's + /// `PermissionRequest` and `Notification:permission_prompt` hooks firing back-to-back for one + /// prompt) schedule only a single notification instead of stacking duplicate banners. + func testDuplicateWaitingForInputEventsScheduleOnlyOneNotification() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001200")!, + title: "Duplicate Waiting Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001201")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001202")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Duplicate Waiting Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + + let firstEvent = AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_050), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + let secondEvent = AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_051), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + eventMonitor.emit(firstEvent) + eventMonitor.emit(secondEvent) + + XCTAssertTrue(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertEqual(notifications.scheduledNotifications.count, 1, "Second duplicate event should not schedule another notification") + XCTAssertEqual(notifications.removedSurfaceIds, [surface.id], "Only the first, non-duplicate event should touch notification removal") + } + + /// Verifies an intervening `.started` event between two waiting-for-input events (a genuine + /// second prompt) is allowed to schedule its own notification, guarding against over-suppression. + func testStartedBetweenWaitingForInputEventsAllowsSecondNotification() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001203")!, + title: "Repeated Prompt Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001204")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001205")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Repeated Prompt Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_060), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + ) + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_061), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .started, + payload: "" + ) + ) + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_062), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + ) + + XCTAssertTrue(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertEqual(notifications.scheduledNotifications.count, 2, "A distinct prompt separated by .started should notify again") + } + + /// Verifies a completed event that follows waiting-for-input directly (no intervening + /// `.started`) clears the stale "Needs Your Input" notification instead of leaving it to + /// linger alongside the new "Finished Responding" banner. + func testWaitingForInputThenCompletedRemovesStaleNotification() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001206")!, + title: "Waiting Then Completed Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001207")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001208")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Waiting Then Completed Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_070), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .waitingForInput, + payload: "" + ) + ) + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_071), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .completed, + payload: "" + ) + ) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertEqual(notifications.removedSurfaceIds, [surface.id, surface.id], "Completion should clear the stale waiting notification") + XCTAssertEqual(notifications.scheduledNotifications.last?.kind, .finished) + } + + /// Verifies closing a surface while it is awaiting input clears the awaiting-input state + /// and requests removal of any delivered notification. + func testClosingSurfaceMidWaitClearsAwaitingInputAndRemovesNotifications() { + let notifications = MockAgentCompletionNotificationManager() + let manager = makeWorkspaceManager(notifications: notifications) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001209")!, + title: "Closed Mid Wait Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001210")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001211")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Closed Mid Wait Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + manager.closeSurface(workspaceId: workspaceId, paneId: paneId, surfaceId: surface.id) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertTrue(notifications.removedSurfaceIds.contains(surface.id)) + } + + /// Verifies an exited event clears any stale "Needs Your Input" notification left behind + /// when an agent process exits directly from a waiting-for-input state. + func testExitedEventRemovesStaleNotification() { + let notifications = MockAgentCompletionNotificationManager() + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager( + notifications: notifications, + eventMonitor: eventMonitor + ) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001212")!, + title: "Exiting While Waiting Surface", + agentType: .claudeCode + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001213")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001214")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Exiting While Waiting Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_005_080), + agentType: .claudeCode, + surfaceId: surface.id, + phase: .exited, + payload: "" + ) + ) + + XCTAssertFalse(manager.awaitingInputSurfaceIds.contains(surface.id)) + XCTAssertTrue(notifications.removedSurfaceIds.contains(surface.id)) + } + + /// Verifies jumpToNextSessionAwaitingInput focuses a surface in the awaiting-input set. + func testJumpToNextSessionAwaitingInputFocusesAwaitingSurface() { + let manager = makeWorkspaceManager() + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001192")!, + title: "Awaiting Input" + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001193")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001194")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: nil + ) + ] + manager.markSurfaceAwaitingInput(surface.id) + + manager.jumpToNextSessionAwaitingInput() + + XCTAssertEqual(manager.window.selectedWorkspaceId, workspaceId) + XCTAssertEqual(manager.workspaces[0].focusedSurfaceId, surface.id) + } } diff --git a/Tests/ShellraiserTests/WorkspaceTestSupport.swift b/Tests/ShellraiserTests/WorkspaceTestSupport.swift index a088d5d..8afa8a4 100644 --- a/Tests/ShellraiserTests/WorkspaceTestSupport.swift +++ b/Tests/ShellraiserTests/WorkspaceTestSupport.swift @@ -190,12 +190,17 @@ final class MockAgentRuntimeBridge: AgentRuntimeSupporting { /// Notification-manager test double that records scheduling and removal. final class MockAgentCompletionNotificationManager: AgentCompletionNotificationManaging { var onActivateSurface: ((UUID) -> Void)? - private(set) var scheduledNotifications: [(target: PendingCompletionTarget, workspaceName: String)] = [] + private(set) var scheduledNotifications: [(target: PendingCompletionTarget, workspaceName: String, kind: AgentNotificationKind)] = [] private(set) var removedSurfaceIds: [UUID] = [] - /// Records notification scheduling requests. + /// Records notification scheduling requests (defaults to .finished kind). func scheduleNotification(target: PendingCompletionTarget, workspaceName: String) { - scheduledNotifications.append((target: target, workspaceName: workspaceName)) + scheduledNotifications.append((target: target, workspaceName: workspaceName, kind: .finished)) + } + + /// Records notification scheduling requests with explicit kind. + func scheduleNotification(target: PendingCompletionTarget, workspaceName: String, kind: AgentNotificationKind) { + scheduledNotifications.append((target: target, workspaceName: workspaceName, kind: kind)) } /// Records notification-removal requests.