Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Sources/Shellraiser/App/ShellraiserApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
32 changes: 26 additions & 6 deletions Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
}
]
}
Expand All @@ -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"
}
]
},
Expand All @@ -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"
}
]
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}]
}
}
Expand Down Expand Up @@ -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
"""#
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Sources/Shellraiser/Models/PaneNodeModel+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Loading
Loading