diff --git a/Shellraiser.xcodeproj/project.pbxproj b/Shellraiser.xcodeproj/project.pbxproj index ce76e9e..f8bfd97 100644 --- a/Shellraiser.xcodeproj/project.pbxproj +++ b/Shellraiser.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ A1000049 /* ShellraiserScripting.xml in Resources */ = {isa = PBXBuildFile; fileRef = A2000049; }; A1000050 /* GitBranchResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000050; }; A1000051 /* WorkspaceManager+GitBranches.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000051; }; + A1000052 /* SurfaceInputEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000052; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -108,6 +109,7 @@ A2000049 /* ShellraiserScripting.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Infrastructure/AppleScript/ShellraiserScripting.xml; sourceTree = ""; }; A2000050 /* GitBranchResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Infrastructure/Git/GitBranchResolver.swift; sourceTree = ""; }; A2000051 /* WorkspaceManager+GitBranches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/Workspaces/WorkspaceManager+GitBranches.swift"; sourceTree = ""; }; + A2000052 /* SurfaceInputEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceInputEvent.swift; sourceTree = ""; }; A3000001 /* Shellraiser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Shellraiser.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -144,6 +146,7 @@ A2000017 /* AgentType.swift */, A2000018 /* TerminalPanelConfig.swift */, A2000019 /* SurfaceModel.swift */, + A2000052 /* SurfaceInputEvent.swift */, A2000020 /* PaneNodeModel.swift */, A2000041 /* PaneNodeModel+Operations.swift */, A2000021 /* WorkspaceModel.swift */, @@ -281,6 +284,7 @@ A1000017 /* AgentType.swift in Sources */, A1000018 /* TerminalPanelConfig.swift in Sources */, A1000019 /* SurfaceModel.swift in Sources */, + A1000052 /* SurfaceInputEvent.swift in Sources */, A1000020 /* PaneNodeModel.swift in Sources */, A1000041 /* PaneNodeModel+Operations.swift in Sources */, A1000021 /* WorkspaceModel.swift in Sources */, diff --git a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift index e0c7bf9..8112f03 100644 --- a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift +++ b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift @@ -13,7 +13,7 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost { terminalConfig: TerminalPanelConfig, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -74,7 +74,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let isFocused: Bool let onActivate: () -> Void let onIdleNotification: () -> Void - let onUserInput: () -> Void + let onInput: (SurfaceInputEvent) -> Void let onTitleChange: (String) -> Void let onWorkingDirectoryChange: (String) -> Void let onChildExited: () -> Void @@ -89,7 +89,7 @@ struct GhosttyTerminalView: NSViewRepresentable { terminalConfig: config, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -104,7 +104,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isFocused: isFocused, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -127,7 +127,7 @@ struct GhosttyTerminalView: NSViewRepresentable { terminalConfig: config, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -142,7 +142,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isFocused: isFocused, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -169,7 +169,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isFocused: Bool, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -191,7 +191,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isFocused: isFocused, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -218,7 +218,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isFocused: Bool, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -229,7 +229,7 @@ struct GhosttyTerminalView: NSViewRepresentable { terminalConfig: config, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 77526c7..654213c 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -203,8 +203,12 @@ struct PaneLeafView: View { // Agent completion notifications are driven by managed // Claude/Codex hooks so queue timing matches actual turns. }, - onUserInput: { - manager.noteSurfaceActivity(workspaceId: workspaceId, surfaceId: activeSurface.id) + onInput: { input in + manager.handleSurfaceInput( + workspaceId: workspaceId, + surfaceId: activeSurface.id, + input: input + ) }, onTitleChange: { title in manager.setSurfaceTitle(workspaceId: workspaceId, surfaceId: activeSurface.id, title: title) diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift index d45bd35..94dea4f 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift @@ -437,33 +437,6 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { | head -n 1 } - extract_codex_surface_id() { - session_line="$1" - surface_value="$( - printf '%s\n' "$session_line" \ - | sed -n 's/.*"surface_id":"\([^"]*\)".*/\1/p' \ - | head -n 1 - )" - if [ -n "$surface_value" ]; then - printf '%s\n' "$surface_value" - return 0 - fi - - surface_value="$( - printf '%s\n' "$session_line" \ - | sed -n 's/.*"notify":\[[^]]*"codex","\([^"]*\)","completed"[^]]*\].*/\1/p' \ - | head -n 1 - )" - if [ -n "$surface_value" ]; then - printf '%s\n' "$surface_value" - return 0 - fi - - printf '%s\n' "$session_line" \ - | sed -n 's/.*"notify":"[^"]*\\\\"codex\\\\",\\\\"\([^"]*\)\\\\",\\\\"completed\\\\"[^"]*".*/\1/p' \ - | head -n 1 - } - normalize_codex_session_timestamp() { timestamp="${1:-}" case "$timestamp" in @@ -497,14 +470,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { [ "$latest_timestamp" = "$candidate_timestamp" ] } - surface_matches_current_codex_session() { - session_line="$1" - candidate_surface_id="$(extract_codex_surface_id "$session_line")" - [ -n "$candidate_surface_id" ] && [ "$candidate_surface_id" = "$surface_id" ] - } - - attempts=0 - while [ "$attempts" -lt 40 ]; do + while :; do [ -f "$stamp_file" ] || exit 0 while IFS= read -r session_file; do @@ -514,10 +480,6 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { continue fi - if ! surface_matches_current_codex_session "$first_line"; then - continue - fi - session_timestamp="$(extract_codex_session_timestamp "$first_line")" if [ -n "$session_timestamp" ] && ! timestamp_is_at_or_after "$session_timestamp" "$start_timestamp"; then continue @@ -536,7 +498,6 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { $(find "$root" -type f -name 'rollout-*.jsonl' -newer "$stamp_file" -print 2>/dev/null | sort -r) EOF - attempts=$((attempts + 1)) sleep 0.5 done } diff --git a/Sources/Shellraiser/Infrastructure/AppleScript/WorkspaceManager+AppleScript.swift b/Sources/Shellraiser/Infrastructure/AppleScript/WorkspaceManager+AppleScript.swift index fc40f38..cf027b5 100644 --- a/Sources/Shellraiser/Infrastructure/AppleScript/WorkspaceManager+AppleScript.swift +++ b/Sources/Shellraiser/Infrastructure/AppleScript/WorkspaceManager+AppleScript.swift @@ -53,7 +53,11 @@ extension WorkspaceManager { focusScriptSurface(target) let didSend = GhosttyRuntime.shared.sendText(surfaceId: target.surfaceId, text: text) if didSend { - noteSurfaceActivity(workspaceId: target.workspaceId, surfaceId: target.surfaceId) + handleSurfaceInput( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId, + input: SurfaceInputEvent.classifyScriptedText(text) + ) } return didSend } @@ -66,7 +70,11 @@ extension WorkspaceManager { focusScriptSurface(target) let didSend = GhosttyRuntime.shared.sendNamedKey(surfaceId: target.surfaceId, keyName: keyName) if didSend { - noteSurfaceActivity(workspaceId: target.workspaceId, surfaceId: target.surfaceId) + handleSurfaceInput( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId, + input: SurfaceInputEvent.classifyScriptedKeyName(keyName) + ) } return didSend } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index 9a780fa..d84f1fa 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -113,7 +113,7 @@ final class GhosttyRuntime { terminalConfig: TerminalPanelConfig, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -134,7 +134,7 @@ final class GhosttyRuntime { terminalConfig: terminalConfig, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, @@ -148,7 +148,7 @@ final class GhosttyRuntime { terminalConfig: terminalConfig, onActivate: onActivate, onIdleNotification: onIdleNotification, - onUserInput: onUserInput, + onInput: onInput, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift index a0b6920..227cccd 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift @@ -11,7 +11,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati private var surfaceHandle: ghostty_surface_t? private var onActivate: () -> Void private var onIdleNotification: () -> Void - private var onUserInput: () -> Void + private var onInput: (SurfaceInputEvent) -> Void private var onTitleChange: (String) -> Void private var onWorkingDirectoryChange: (String) -> Void private var onChildExited: () -> Void @@ -32,7 +32,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati terminalConfig: TerminalPanelConfig, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -42,7 +42,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.terminalConfig = terminalConfig self.onActivate = onActivate self.onIdleNotification = onIdleNotification - self.onUserInput = onUserInput + self.onInput = onInput self.onTitleChange = onTitleChange self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited @@ -162,6 +162,10 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati ) let translatedEvent = translatedKeyEvent(from: event, modifiers: translatedModifiers) + let inputEvent = SurfaceInputEvent.classifyUserKey( + keyCode: event.keyCode, + modifiers: translatedEvent.modifierFlags + ) let action: ghostty_input_action_e = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS let markedTextBefore = markedText.length > 0 @@ -171,7 +175,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati !translatedEvent.modifierFlags.contains(.command), !translatedEvent.modifierFlags.contains(.control), !translatedEvent.modifierFlags.contains(.option) { - onUserInput() + onInput(inputEvent) GhosttyRuntime.shared.sendKeyEvent( surfaceId: surfaceModel.id, event: event, @@ -194,7 +198,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati if let textEvents = keyTextAccumulator, !textEvents.isEmpty { for text in textEvents { - onUserInput() + onInput(inputEvent) GhosttyRuntime.shared.sendKeyEvent( surfaceId: surfaceModel.id, event: event, @@ -208,7 +212,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati } if didInterpretCommand { - onUserInput() + onInput(inputEvent) GhosttyRuntime.shared.sendKeyEvent( surfaceId: surfaceModel.id, event: event, @@ -219,7 +223,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati return } - onUserInput() + onInput(inputEvent) GhosttyRuntime.shared.sendKeyEvent( surfaceId: surfaceModel.id, event: event, @@ -422,7 +426,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati terminalConfig: TerminalPanelConfig, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -432,7 +436,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.terminalConfig = terminalConfig self.onActivate = onActivate self.onIdleNotification = onIdleNotification - self.onUserInput = onUserInput + self.onInput = onInput self.onTitleChange = onTitleChange self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited @@ -592,7 +596,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati } guard !text.isEmpty else { return } - onUserInput() + onInput(.userInput) GhosttyRuntime.shared.sendText(surfaceId: surfaceModel.id, text: text) } @@ -606,7 +610,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati /// Exposes paste support to the AppKit responder chain. @objc func paste(_ sender: Any?) { - onUserInput() + onInput(.userInput) _ = GhosttyRuntime.shared.performBindingAction( surfaceId: surfaceModel.id, action: "paste_from_clipboard" @@ -637,7 +641,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati /// Accepts AppKit service paste operations. func readSelection(from pboard: NSPasteboard) -> Bool { guard let text = pboard.opinionatedStringContents, !text.isEmpty else { return false } - onUserInput() + onInput(.userInput) GhosttyRuntime.shared.sendText(surfaceId: surfaceModel.id, text: text) return true } diff --git a/Sources/Shellraiser/Models/SurfaceInputEvent.swift b/Sources/Shellraiser/Models/SurfaceInputEvent.swift new file mode 100644 index 0000000..2090cef --- /dev/null +++ b/Sources/Shellraiser/Models/SurfaceInputEvent.swift @@ -0,0 +1,62 @@ +import AppKit +import Foundation + +/// Categorizes terminal activity into generic input versus likely prompt submission. +enum SurfaceInputEvent: Equatable { + case userInput + case userSubmit + case scriptedInput + case scriptedSubmit + + /// Returns whether the input should be treated as a prompt submission. + var isSubmit: Bool { + switch self { + case .userSubmit, .scriptedSubmit: + return true + case .userInput, .scriptedInput: + return false + } + } + + /// Classifies a keyboard event using the submit-like keys supported by Shellraiser. + static func classifyUserKey( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags + ) -> SurfaceInputEvent { + let relevantModifiers = modifiers.intersection([.shift, .control, .option, .command]) + + if keyCode == 36 || keyCode == 76 { + return .userSubmit + } + + if keyCode == 46, + (relevantModifiers == [.control] || relevantModifiers == [.shift, .control]) { + return .userSubmit + } + + return .userInput + } + + /// Classifies scripted text input by whether it ends with a submitted newline. + static func classifyScriptedText(_ text: String) -> SurfaceInputEvent { + guard let lastScalar = text.unicodeScalars.last else { + return .scriptedInput + } + + if lastScalar == "\n" || lastScalar == "\r" { + return .scriptedSubmit + } + + return .scriptedInput + } + + /// Classifies AppleScript named keys into generic input versus prompt submission. + static func classifyScriptedKeyName(_ keyName: String) -> SurfaceInputEvent { + switch keyName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "enter", "return": + return .scriptedSubmit + default: + return .scriptedInput + } + } +} diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift index 4dbd4c4..9fc44ef 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift @@ -128,11 +128,13 @@ extension WorkspaceManager { func handleAgentActivityEvent(_ event: AgentActivityEvent) { guard let target = surfaceTarget(for: event.surfaceId) else { clearBusySurface(event.surfaceId) + clearLiveCodexSessionSurface(event.surfaceId) return } switch event.phase { case .started: + guard event.agentType != .codex else { return } markSurfaceBusy(event.surfaceId) case .completed: clearBusySurface(event.surfaceId) @@ -154,8 +156,12 @@ extension WorkspaceManager { workspaces: &workspaces, persistence: persistence ) + if event.agentType == .codex { + markLiveCodexSessionSurface(event.surfaceId) + } case .exited: clearBusySurface(event.surfaceId) + clearLiveCodexSessionSurface(event.surfaceId) guard !isTerminating else { return } surfaceManager.setResumeEligibility( workspaceId: target.workspaceId, @@ -335,4 +341,19 @@ extension WorkspaceManager { func clearBusySurfaces(_ surfaceIds: S) where S.Element == UUID { busySurfaceIds.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+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index e0ad159..2c4e271 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -40,6 +40,7 @@ extension WorkspaceManager { completionNotifications.removeNotifications(for: surfaceId) GhosttyRuntime.shared.releaseSurface(surfaceId: surfaceId) clearBusySurface(surfaceId) + clearLiveCodexSessionSurface(surfaceId) clearGitBranch(surfaceId: surfaceId) if let workspace = workspace(id: workspaceId), @@ -116,8 +117,8 @@ extension WorkspaceManager { ) } - /// Records generic activity for a surface when the terminal receives user input. - func noteSurfaceActivity(workspaceId: UUID, surfaceId: UUID) { + /// Records terminal activity and marks gated Codex submit events as busy. + func handleSurfaceInput(workspaceId: UUID, surfaceId: UUID, input: SurfaceInputEvent) { surfaceManager.setIdleState( workspaceId: workspaceId, surfaceId: surfaceId, @@ -132,6 +133,9 @@ extension WorkspaceManager { return } + guard input.isSubmit else { return } + guard liveCodexSessionSurfaceIds.contains(surfaceId) else { return } + markSurfaceBusy(surfaceId) } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift index fc09d88..6b0804f 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift @@ -61,6 +61,7 @@ extension WorkspaceManager { persistence: persistence ) clearBusySurfaces(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 b7fb28c..a9a6128 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -72,6 +72,7 @@ final class WorkspaceManager: ObservableObject { @Published var pendingWorkspaceRename: WorkspaceRenameRequest? @Published var gitStatesBySurfaceId: [UUID: ResolvedGitState] = [:] @Published var busySurfaceIds: Set = [] + var liveCodexSessionSurfaceIds: Set = [] let persistence: any WorkspacePersisting let workspaceCatalog: WorkspaceCatalogManager diff --git a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift index 3da8b88..d205b27 100644 --- a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift +++ b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift @@ -73,11 +73,15 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertTrue(codexWrapperContents.contains("monitor_codex_session")) XCTAssertTrue(codexWrapperContents.contains("codex \"$surface\" session")) XCTAssertTrue(codexWrapperContents.contains("codex \"$surface\" exited")) + XCTAssertFalse(codexWrapperContents.contains("codex \"$surface\" started")) XCTAssertTrue(codexWrapperContents.contains("extract_codex_session_timestamp")) - XCTAssertTrue(codexWrapperContents.contains("extract_codex_surface_id")) - XCTAssertTrue(codexWrapperContents.contains("surface_matches_current_codex_session")) + XCTAssertFalse(codexWrapperContents.contains("extract_codex_surface_id")) + XCTAssertFalse(codexWrapperContents.contains("surface_matches_current_codex_session")) XCTAssertTrue(codexWrapperContents.contains("normalize_codex_session_timestamp")) XCTAssertTrue(codexWrapperContents.contains("timestamp_is_at_or_after")) + XCTAssertTrue(codexWrapperContents.contains("while :; do")) + XCTAssertFalse(codexWrapperContents.contains("while [ \"$attempts\" -lt 40 ]; do")) + XCTAssertFalse(codexWrapperContents.contains("attempts=$((attempts + 1))")) XCTAssertTrue(codexWrapperContents.contains("printf '%-9.9s'")) XCTAssertTrue(codexWrapperContents.contains("monitor_pid=\"$!\"")) XCTAssertTrue(codexWrapperContents.contains("rm -f \"$stamp_file\"")) diff --git a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift index e0e7ca0..0c32a6d 100644 --- a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift +++ b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift @@ -25,7 +25,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: true, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -61,7 +61,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: true, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -76,7 +76,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: true, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -114,7 +114,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: false, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -129,7 +129,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: true, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -165,7 +165,7 @@ final class GhosttyTerminalViewTests: XCTestCase { isFocused: false, onActivate: {}, onIdleNotification: {}, - onUserInput: {}, + onInput: { _ in }, onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, @@ -199,7 +199,7 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView terminalConfig: TerminalPanelConfig, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, - onUserInput: @escaping () -> Void, + onInput: @escaping (SurfaceInputEvent) -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, @@ -208,7 +208,7 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView _ = terminalConfig _ = onActivate _ = onIdleNotification - _ = onUserInput + _ = onInput _ = onTitleChange workingDirectoryChangeHandler = onWorkingDirectoryChange _ = onChildExited diff --git a/Tests/ShellraiserTests/SurfaceInputEventTests.swift b/Tests/ShellraiserTests/SurfaceInputEventTests.swift new file mode 100644 index 0000000..9df47ba --- /dev/null +++ b/Tests/ShellraiserTests/SurfaceInputEventTests.swift @@ -0,0 +1,48 @@ +import AppKit +import XCTest +@testable import Shellraiser + +/// Covers submit-like input classification used by Codex busy heuristics. +final class SurfaceInputEventTests: XCTestCase { + /// Verifies Return-like keys are classified as user submissions. + func testClassifyUserKeyTreatsReturnEnterAndControlMAsSubmit() { + XCTAssertEqual( + SurfaceInputEvent.classifyUserKey(keyCode: 36, modifiers: []), + .userSubmit + ) + XCTAssertEqual( + SurfaceInputEvent.classifyUserKey(keyCode: 76, modifiers: []), + .userSubmit + ) + XCTAssertEqual( + SurfaceInputEvent.classifyUserKey(keyCode: 46, modifiers: [.control]), + .userSubmit + ) + } + + /// Verifies ambiguous or generic keys remain non-submit input. + func testClassifyUserKeyLeavesControlJAndPrintableKeysAsInput() { + XCTAssertEqual( + SurfaceInputEvent.classifyUserKey(keyCode: 38, modifiers: [.control]), + .userInput + ) + XCTAssertEqual( + SurfaceInputEvent.classifyUserKey(keyCode: 46, modifiers: []), + .userInput + ) + } + + /// Verifies scripted text only submits when it ends with a newline. + func testClassifyScriptedTextRequiresTrailingNewline() { + XCTAssertEqual(SurfaceInputEvent.classifyScriptedText("codex"), .scriptedInput) + XCTAssertEqual(SurfaceInputEvent.classifyScriptedText("codex\n"), .scriptedSubmit) + XCTAssertEqual(SurfaceInputEvent.classifyScriptedText("codex\r"), .scriptedSubmit) + } + + /// Verifies only Enter-like AppleScript key names are treated as submit. + func testClassifyScriptedKeyNameRequiresEnterOrReturn() { + XCTAssertEqual(SurfaceInputEvent.classifyScriptedKeyName("enter"), .scriptedSubmit) + XCTAssertEqual(SurfaceInputEvent.classifyScriptedKeyName("return"), .scriptedSubmit) + XCTAssertEqual(SurfaceInputEvent.classifyScriptedKeyName("tab"), .scriptedInput) + } +} diff --git a/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift b/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift index 66ed894..6d53add 100644 --- a/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift +++ b/Tests/ShellraiserTests/WorkspaceManagerCompletionTests.swift @@ -229,6 +229,39 @@ final class WorkspaceManagerCompletionTests: WorkspaceTestCase { XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) } + /// Verifies Codex started events are ignored so launch does not mark a workspace busy. + func testCodexStartedEventDoesNotMarkWorkspaceBusy() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001170")!, + title: "Codex Launch Surface", + agentType: .codex + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001171")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001172")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Codex Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_004_521), + agentType: .codex, + surfaceId: surface.id, + phase: .started, + payload: "" + ) + ) + + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + } + /// Verifies session-identity events persist the resolved runtime and resume identifier. func testSessionEventPersistsSurfaceSessionIdentity() { let eventMonitor = MockAgentActivityEventMonitor() @@ -411,8 +444,8 @@ final class WorkspaceManagerCompletionTests: WorkspaceTestCase { XCTAssertEqual(persistence.load(), manager.workspaces) } - /// Verifies Codex terminal input marks the owning workspace busy until completion is observed. - func testNoteSurfaceActivityMarksCodexWorkspaceBusy() { + /// Verifies submit-only Codex input does not mark busy before runtime session discovery. + func testHandleSurfaceInputDoesNotMarkCodexBusyWithoutKnownSession() { let manager = makeWorkspaceManager() let surface = makeSurface( id: UUID(uuidString: "00000000-0000-0000-0000-000000001137")!, @@ -430,11 +463,141 @@ final class WorkspaceManagerCompletionTests: WorkspaceTestCase { ) ] - manager.noteSurfaceActivity(workspaceId: workspaceId, surfaceId: surface.id) + manager.handleSurfaceInput( + workspaceId: workspaceId, + surfaceId: surface.id, + input: .userSubmit + ) + + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + } + + /// Verifies Codex submit input marks the workspace busy once runtime session identity is known. + func testHandleSurfaceInputMarksCodexBusyAfterSessionEvent() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001161")!, + title: "Codex Submit Surface", + agentType: .codex + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001162")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001163")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Codex Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_004_540), + agentType: .codex, + surfaceId: surface.id, + phase: .session, + payload: "session-123" + ) + ) + + manager.handleSurfaceInput( + workspaceId: workspaceId, + surfaceId: surface.id, + input: .userSubmit + ) XCTAssertTrue(manager.isWorkspaceWorking(workspaceId: workspaceId)) } + /// Verifies non-submit Codex input does not mark the workspace busy after session discovery. + func testHandleSurfaceInputIgnoresNonSubmitCodexInput() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001164")!, + title: "Codex Typing Surface", + agentType: .codex + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001165")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001166")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Codex Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_004_541), + agentType: .codex, + surfaceId: surface.id, + phase: .session, + payload: "session-456" + ) + ) + + manager.handleSurfaceInput( + workspaceId: workspaceId, + surfaceId: surface.id, + input: .userInput + ) + + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + } + + /// Verifies runtime exit clears the Codex submit gate after a session was known. + func testExitedEventClearsKnownCodexSessionGate() { + let eventMonitor = MockAgentActivityEventMonitor() + let manager = makeWorkspaceManager(eventMonitor: eventMonitor) + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-000000001167")!, + title: "Codex Exit Surface", + agentType: .codex + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-000000001168")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001169")! + manager.workspaces = [ + makeWorkspace( + id: workspaceId, + name: "Codex Workspace", + rootPane: makeLeaf(paneId: paneId, surfaces: [surface], activeSurfaceId: surface.id), + focusedSurfaceId: surface.id + ) + ] + + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_004_542), + agentType: .codex, + surfaceId: surface.id, + phase: .session, + payload: "session-789" + ) + ) + eventMonitor.emit( + AgentActivityEvent( + timestamp: Date(timeIntervalSince1970: 1_700_004_543), + agentType: .codex, + surfaceId: surface.id, + phase: .exited, + payload: "" + ) + ) + + manager.handleSurfaceInput( + workspaceId: workspaceId, + surfaceId: surface.id, + input: .scriptedSubmit + ) + + XCTAssertFalse(manager.isWorkspaceWorking(workspaceId: workspaceId)) + } + /// Verifies closing a busy surface clears the workspace working indicator state. func testCloseSurfaceClearsBusyWorkspaceState() { let manager = makeWorkspaceManager()