diff --git a/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift b/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift index 06c170f567ab..5d4989a3fa2b 100644 --- a/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift +++ b/Packages/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/OSC133CommandParser.swift @@ -15,7 +15,7 @@ import Foundation /// Pure and incremental: ``consume(_:)`` may be fed arbitrary chunk /// boundaries, including ones that split an escape sequence (the tail is /// carried over). Read ``blocks`` after feeding. -public final class OSC133CommandParser { +public struct OSC133CommandParser { /// The command blocks parsed so far, oldest first. public private(set) var blocks: [TerminalCommandBlock] = [] @@ -48,7 +48,7 @@ public final class OSC133CommandParser { /// Feeds a chunk of raw terminal output through the state machine. /// /// - Parameter text: A slice of the PTY stream, any length. - public func consume(_ text: String) { + public mutating func consume(_ text: String) { let stream = pending + text pending = "" var index = stream.startIndex @@ -78,7 +78,7 @@ public final class OSC133CommandParser { /// Publishes the running block's output: the already-folded completed /// lines plus the open line folded on its own (O(open line), not O(total)). - private func flushOpenOutput() { + private mutating func flushOpenOutput() { guard phase == .output, let openIndex else { return } blocks[openIndex].output = foldedOutput + Self.foldLine(openLine) } @@ -206,7 +206,7 @@ public final class OSC133CommandParser { // MARK: - State transitions - private func apply(_ action: EscapeAction) { + private mutating func apply(_ action: EscapeAction) { switch action { case .promptStart: finalizeOpenOutput() @@ -231,7 +231,7 @@ public final class OSC133CommandParser { } } - private func appendText(_ char: Character) { + private mutating func appendText(_ char: Character) { switch phase { case .command: commandBuffer.append(char) @@ -251,7 +251,7 @@ public final class OSC133CommandParser { } } - private func openBlock() { + private mutating func openBlock() { let block = TerminalCommandBlock( id: nextID, command: commandBuffer.trimmingCharacters(in: .whitespacesAndNewlines), @@ -264,7 +264,7 @@ public final class OSC133CommandParser { openIndex = blocks.count - 1 } - private func closeBlock(exitCode: Int?) { + private mutating func closeBlock(exitCode: Int?) { guard let openIndex else { return } blocks[openIndex].output = foldedOutput + Self.foldLine(openLine) blocks[openIndex].exitCode = exitCode @@ -274,7 +274,7 @@ public final class OSC133CommandParser { openLine = "" } - private func finalizeOpenOutput() { + private mutating func finalizeOpenOutput() { // A new prompt without a D mark (e.g. Ctrl-C, or a shell that skipped // D): close the open block with an unknown exit code. if openIndex != nil { closeBlock(exitCode: nil) } diff --git a/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift b/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift index 826f45764f13..122716ebd3ff 100644 --- a/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift +++ b/Packages/CmuxAgentChat/Tests/CmuxAgentChatTests/OSC133CommandParserTests.swift @@ -12,7 +12,7 @@ struct OSC133CommandParserTests { @Test("a complete command/output/exit cycle yields one finished block") func happyPath() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + "user@host$ " + mark("B") + "echo hi" + mark("C") + "hi\n" + mark("D;0")) #expect(parser.blocks.count == 1) let block = parser.blocks[0] @@ -25,7 +25,7 @@ struct OSC133CommandParserTests { @Test("a nonzero exit code marks the block failed") func failure() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "false" + mark("C") + mark("D;1")) #expect(parser.blocks[0].exitCode == 1) #expect(parser.blocks[0].failed) @@ -33,7 +33,7 @@ struct OSC133CommandParserTests { @Test("a block with no D mark stays running until the next prompt closes it") func runningUntilNextPrompt() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "sleep 5" + mark("C") + "working") #expect(parser.blocks.count == 1) #expect(parser.blocks[0].isRunning) @@ -46,7 +46,7 @@ struct OSC133CommandParserTests { @Test("two commands produce two blocks with distinct ids") func twoCommands() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() parser.consume(mark("A") + mark("B") + "ls" + mark("C") + "a b\n" + mark("D;0")) parser.consume(mark("A") + mark("B") + "pwd" + mark("C") + "/tmp\n" + mark("D;0")) #expect(parser.blocks.count == 2) @@ -57,7 +57,7 @@ struct OSC133CommandParserTests { @Test("an escape sequence split across chunks is parsed once completed") func splitEscape() { - let parser = OSC133CommandParser() + var parser = OSC133CommandParser() let full = mark("A") + mark("B") + "id" + mark("C") + "uid=0\n" + mark("D;0") let mid = full.index(full.startIndex, offsetBy: 3) parser.consume(String(full[.. Void + ) { + self.center = center + observer = center.addObserver( + forName: Notification.Name.ghosttyDidSetTitle, + object: nil, + queue: .main + ) { notification in + guard let change = GhosttyTitleChange(notification: notification) else { return } + Task { @MainActor in + handler(change) + } + } + } + + deinit { + center.removeObserver(observer) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 636c7a17b156..fcd19f3c2591 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -7,14 +7,6 @@ import Foundation /// `chat.message` events to subscribed mobile clients. @MainActor final class AgentChatTranscriptService { - /// Process-wide instance, mirroring the sibling mobile host services - /// (`MobileHostService.shared`, `MobileTerminalRenderObserver.shared`) - /// that the socket dispatch reaches statically. lint:allow - static let shared = AgentChatTranscriptService( - registry: AgentChatSessionRegistry(), - resolver: AgentChatTranscriptResolver() - ) - /// The push topic chat clients subscribe to. static let eventTopic = "chat.message" @@ -32,17 +24,25 @@ final class AgentChatTranscriptService { /// title-detected claude has not yet written its transcript; a successful /// adoption removes the entry. private var detectionScanAt: [String: Date] = [:] + private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? private static let detectionScanThrottle: TimeInterval = 4 private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" - /// Creates the service. + /// Creates the service with a hook-store-backed registry. + /// + /// - Parameter resolver: Transcript path resolver. + convenience init(resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver()) { + self.init(registry: AgentChatSessionRegistry(), resolver: resolver) + } + + /// Creates the service with explicit dependencies. /// /// - Parameters: - /// - registry: Session registry; defaults to a hook-store-backed one. + /// - registry: Session registry. /// - resolver: Transcript path resolver. init( registry: AgentChatSessionRegistry, - resolver: AgentChatTranscriptResolver + resolver: AgentChatTranscriptResolver = AgentChatTranscriptResolver() ) { self.registry = registry self.resolver = resolver @@ -53,9 +53,13 @@ final class AgentChatTranscriptService { /// Seeds the session registry from the on-disk hook stores. Call once /// at app startup. - func start() { + /// + /// - Parameter adoptDetectedAgentSessions: Composition-root callback that + /// adopts a title-detected agent for the workspace whose title changed. + func start(adoptDetectedAgentSessions: @escaping @MainActor (String) -> Void) { + guard ghosttyTitleSubscription == nil else { return } registry.seedFromHookStores() - observeAgentTitleChanges() + observeAgentTitleChanges(adoptDetectedAgentSessions: adoptDetectedAgentSessions) } /// Watches terminal title changes so a coding agent launched without a @@ -63,20 +67,12 @@ final class AgentChatTranscriptService { /// adopted the instant its terminal title becomes the agent's (e.g. /// "✳ Claude Code"), not only when the workspace is next opened. Adoption /// emits a descriptor change, which pushes the toggle to listening phones. - private func observeAgentTitleChanges() { - NotificationCenter.default.addObserver( - forName: .ghosttyDidSetTitle, - object: nil, - queue: .main - ) { notification in - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - let title = notification.userInfo?[GhosttyNotificationKey.title] as? String, - title.lowercased().contains("claude") else { + private func observeAgentTitleChanges(adoptDetectedAgentSessions: @escaping @MainActor (String) -> Void) { + ghosttyTitleSubscription = GhosttyTitleChangeSubscription { change in + guard change.title.lowercased().contains("claude") else { return } - MainActor.assumeIsolated { - TerminalController.shared.adoptDetectedAgentSessions(workspaceID: tabId.uuidString) - } + adoptDetectedAgentSessions(change.tabId.uuidString) } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 93a0c3918329..bd50044d32f6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -560,10 +560,8 @@ class TabManager: ObservableObject { ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in guard let self else { return } - guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } - guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } - guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } - enqueuePanelTitleUpdate(tabId: tabId, panelId: surfaceId, title: title) + guard let change = GhosttyTitleChange(notification: notification) else { return } + enqueuePanelTitleUpdate(tabId: change.tabId, panelId: change.surfaceId, title: change.title) } }) observers.append(NotificationCenter.default.addObserver( diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 0e0c9d9477f9..d17efc273c06 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -18,6 +18,15 @@ extension TerminalController { ) } + /// Error shown when the Mac-side chat service is not wired into this + /// process. Surfaces in mobile RPC error banners and debug responses. + static var chatServiceUnavailableErrorMessage: String { + String( + localized: "mobile.chat.error.serviceUnavailable", + defaultValue: "Agent chat transcript service is not configured" + ) + } + /// Routes one `mobile.chat.*` method to its handler (single dispatch /// case in `mobileHostHandleRPC` keeps the god-file growth flat). func v2MobileChatDispatch(method: String, params: [String: Any]) async -> V2CallResult { @@ -43,14 +52,19 @@ extension TerminalController { /// full chat-session registry state, for diagnosing inconsistent /// phone-side states. func v2ChatSessionsDump() -> V2CallResult { - .ok(["sessions": AgentChatTranscriptService.shared.debugSessionDump()]) + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) + } + return .ok(["sessions": service.debugSessionDump()]) } /// `mobile.chat.sessions`: list chat-capable coding-agent sessions, /// optionally scoped to one workspace. func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { let workspaceID = v2String(params, "workspace_id") - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) + } // Register coding agents cmux detects by terminal title but that never // ran a hook (e.g. launched through a shell wrapper that bypasses // cmux's hook injection), so they get a chat session and toggle like @@ -89,7 +103,7 @@ extension TerminalController { /// workspace touches no filesystem. func adoptDetectedAgentSessions(workspace: Workspace) { let workspaceID = workspace.id.uuidString - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { return } for panel in workspace.panels.values.compactMap({ $0 as? TerminalPanel }) { let context = WorkspaceContentView.terminalAgentContext(panel: panel, workspace: workspace) let title = workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle @@ -121,7 +135,9 @@ extension TerminalController { } let limit = min(max(v2Int(params, "limit") ?? 100, 1), 200) let beforeSeq = v2Int(params, "before_seq") - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) + } var page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) if page == nil, let staleRecord = service.sessionRecord(sessionID: sessionID) { // The record exists but its transcript didn't resolve — the @@ -132,7 +148,7 @@ extension TerminalController { #if DEBUG cmuxDebugLog("mobile.chat.history transcript unresolved session=\(sessionID.prefix(8)); refreshing bindings") #endif - let refreshed = AgentChatTranscriptService.shared.refreshSessionBindings(sessionID: sessionID) + let refreshed = service.refreshSessionBindings(sessionID: sessionID) if refreshed?.transcriptPath != staleRecord.transcriptPath || refreshed?.workingDirectory != staleRecord.workingDirectory { page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) @@ -294,7 +310,7 @@ extension TerminalController { /// retried. If it still doesn't resolve we fail with an actionable error /// rather than redirect the prompt to some other terminal. private func mobileChatTerminalParams(sessionID: String) -> [String: Any]? { - let service = AgentChatTranscriptService.shared + guard let service = agentChatTranscriptService else { return nil } guard let record = service.sessionRecord(sessionID: sessionID), let workspaceID = record.workspaceID else { return nil diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6ea874349bb2..8f4bd117744d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -108,6 +108,7 @@ class TerminalController { /// listener starts. Socket auth commands read these on the main actor. @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? + @MainActor var agentChatTranscriptService: AgentChatTranscriptService? // Sendable value type; injected at construction so socket auth never reaches a global. private nonisolated let passwordStore: SocketControlPasswordStore /// Process-wide proxy-tunnel broker (one shared tunnel per remote transport across all @@ -329,7 +330,6 @@ class TerminalController { } } } - nonisolated func currentSocketPathForRemoteRestore() -> String? { socketServer.currentSocketPathForRemoteRestore() } @@ -5312,7 +5312,7 @@ class TerminalController { CmuxEventBus.shared.publishWorkstreamEvent(event, phase: "received") v2ApplyIMessageModeSideEffects(for: event) - Task { @MainActor in AgentChatTranscriptService.shared.noteHookEvent(event) } + Task { @MainActor in self.agentChatTranscriptService?.noteHookEvent(event) } let result = FeedCoordinator.shared.ingestBlocking( event: event, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ba74ee689ced..599bbaeb6c79 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -457,6 +457,8 @@ D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; }; D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */; }; + D7C6F5000000000000000000 /* GhosttyTitleChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6F5000000000000000001 /* GhosttyTitleChange.swift */; }; + D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */; }; 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */; }; 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */; }; 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */; }; @@ -1399,6 +1401,8 @@ D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTextInputSupport.swift; sourceTree = ""; }; + D7C6F5000000000000000001 /* GhosttyTitleChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChange.swift; sourceTree = ""; }; + D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleChangeSubscription.swift; sourceTree = ""; }; 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchCoordinator.swift; sourceTree = ""; }; 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchDocuments.swift; sourceTree = ""; }; 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchPanelCaptureManager.swift; sourceTree = ""; }; @@ -2168,6 +2172,8 @@ 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, A11EAB000000000000000001 /* AppearanceSettings.swift */, A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */, + D7C6F5000000000000000001 /* GhosttyTitleChange.swift */, + D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, @@ -3641,6 +3647,8 @@ A5001005 /* GhosttyTerminalView.swift in Sources */, D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D3284101A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift in Sources */, + D7C6F5000000000000000000 /* GhosttyTitleChange.swift in Sources */, + D7C6F5010000000000000000 /* GhosttyTitleChangeSubscription.swift in Sources */, 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */, 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */,