native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #136 from onevcat/onevclaw/issue-108-focus-v1-runtime

CLI/focus v1 runtime implementation (contract-driven)

authored by

Wei Wang and committed by
GitHub
eeccd4c7 d19ad6da

+717 -2
+1 -1
ProwlCLI/Commands/FocusCommand.swift
··· 13 13 @OptionGroup var options: GlobalOptions 14 14 15 15 mutating func run() throws { 16 - try CLIExecution.run(command: "focus", output: options.outputMode) { 16 + try CLIExecution.run(command: "focus", output: options.outputMode, colorEnabled: options.colorEnabled) { 17 17 let sel = try selector.resolve() 18 18 let envelope = CommandEnvelope( 19 19 output: options.outputMode,
+34
ProwlCLI/Output/OutputRenderer.swift
··· 57 57 return 58 58 } 59 59 60 + if response.command == "focus", 61 + let data = response.data, 62 + let payload = try? data.decode(as: FocusCommandPayload.self) 63 + { 64 + print(renderFocus(payload)) 65 + return 66 + } 67 + 60 68 print("ok: \(response.command)") 61 69 return 62 70 } ··· 185 193 lines.append(" \("wait:".dim) \("none (fire-and-forget)".dim)") 186 194 } 187 195 196 + return lines.joined(separator: "\n") 197 + } 198 + 199 + private static func renderFocus(_ payload: FocusCommandPayload) -> String { 200 + let wt = payload.target.worktree 201 + let tab = payload.target.tab 202 + let pane = payload.target.pane 203 + 204 + let projectName = projectName(from: wt.path) 205 + let frontLabel = payload.broughtToFront ? "yes".green : "no".red.bold 206 + let requestedValue = payload.requested.value ?? "current" 207 + 208 + var lines: [String] = [] 209 + lines.append( 210 + "Focused \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" 211 + + " \(pane.id.dim)" 212 + ) 213 + lines.append( 214 + " \("requested:".dim) \(payload.requested.selector.rawValue)=\(requestedValue)" 215 + + " \("resolved:".dim) \(payload.resolvedVia.rawValue)" 216 + + " \("front:".dim) \(frontLabel)" 217 + ) 218 + lines.append(" \("tab:".dim) \(tab.title) \(tab.id.dim)") 219 + if let cwd = pane.cwd { 220 + lines.append(" \("cwd:".dim) \(cwd)") 221 + } 188 222 return lines.joined(separator: "\n") 189 223 } 190 224
+119
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 100 100 XCTAssertEqual(payload["command"] as? String, "focus") 101 101 } 102 102 103 + func testFocusCommandWithoutSelectorSendsCurrentTarget() throws { 104 + let socketPath = temporarySocketPath(suffix: "focus-current") 105 + let response = CommandResponse( 106 + ok: true, 107 + command: "focus", 108 + schemaVersion: "prowl.cli.focus.v1" 109 + ) 110 + 111 + let (requestData, result) = try runWithMockServer( 112 + socketPath: socketPath, 113 + response: response, 114 + args: ["focus", "--json"] 115 + ) 116 + 117 + XCTAssertEqual(result.exitCode, 0) 118 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 119 + if case .focus(let input) = envelope.command { 120 + XCTAssertEqual(input.selector, .none) 121 + } else { 122 + XCTFail("Expected focus command envelope") 123 + } 124 + } 125 + 126 + func testFocusRejectsMultipleSelectorsBeforeTransport() throws { 127 + let result = try runProwl(args: ["focus", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) 128 + 129 + XCTAssertNotEqual(result.exitCode, 0) 130 + let payload = try jsonObject(from: result.stdout) 131 + XCTAssertEqual(payload["ok"] as? Bool, false) 132 + XCTAssertEqual(payload["command"] as? String, "focus") 133 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 134 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) 135 + } 136 + 137 + func testFocusCommandTextRenderingFromSocket() throws { 138 + let socketPath = temporarySocketPath(suffix: "focus-text") 139 + let response = try CommandResponse( 140 + ok: true, 141 + command: "focus", 142 + schemaVersion: "prowl.cli.focus.v1", 143 + data: RawJSON(encoding: FocusResponseData( 144 + requested: FocusRequested(selector: "pane", value: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764"), 145 + resolvedVia: "pane", 146 + broughtToFront: true, 147 + target: FocusResponseTarget( 148 + worktree: ListWorktree( 149 + id: "Prowl:/Users/onevcat/Projects/Prowl", 150 + name: "Prowl", 151 + path: "/Users/onevcat/Projects/Prowl", 152 + rootPath: "/Users/onevcat/Projects/Prowl", 153 + kind: "git" 154 + ), 155 + tab: FocusResponseTab( 156 + id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", 157 + title: "Prowl 1", 158 + selected: true 159 + ), 160 + pane: FocusResponsePane( 161 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 162 + title: "zsh", 163 + cwd: "/Users/onevcat/Projects/Prowl", 164 + focused: true 165 + ) 166 + ) 167 + )) 168 + ) 169 + 170 + let (_, result) = try runWithMockServer( 171 + socketPath: socketPath, 172 + response: response, 173 + args: ["focus"] 174 + ) 175 + 176 + XCTAssertEqual(result.exitCode, 0) 177 + XCTAssertTrue(result.stdout.contains("Focused Prowl:Prowl"), "Missing focus header: \(result.stdout)") 178 + XCTAssertTrue(result.stdout.contains("requested: pane"), "Missing requested field: \(result.stdout)") 179 + XCTAssertTrue(result.stdout.contains("resolved: pane"), "Missing resolved field: \(result.stdout)") 180 + XCTAssertTrue(result.stdout.contains("tab: Prowl 1"), "Missing tab field: \(result.stdout)") 181 + } 182 + 103 183 104 184 func testListCommandTextRenderingFromSocket() throws { 105 185 let socketPath = temporarySocketPath(suffix: "list-text") ··· 697 777 698 778 private struct ListTask: Encodable { 699 779 let status: String? 780 + } 781 + 782 + private struct FocusResponseData: Encodable { 783 + let requested: FocusRequested 784 + 785 + enum CodingKeys: String, CodingKey { 786 + case requested 787 + case resolvedVia = "resolved_via" 788 + case broughtToFront = "brought_to_front" 789 + case target 790 + } 791 + 792 + let resolvedVia: String 793 + let broughtToFront: Bool 794 + let target: FocusResponseTarget 795 + } 796 + 797 + private struct FocusRequested: Encodable { 798 + let selector: String 799 + let value: String? 800 + } 801 + 802 + private struct FocusResponseTarget: Encodable { 803 + let worktree: ListWorktree 804 + let tab: FocusResponseTab 805 + let pane: FocusResponsePane 806 + } 807 + 808 + private struct FocusResponseTab: Encodable { 809 + let id: String 810 + let title: String 811 + let selected: Bool 812 + } 813 + 814 + private struct FocusResponsePane: Encodable { 815 + let id: String 816 + let title: String 817 + let cwd: String? 818 + let focused: Bool 700 819 } 701 820 702 821 private struct SendResponseData: Encodable {
+69 -1
supacode/App/supacodeApp.swift
··· 229 229 .waitForCommandFinished(surfaceID: surfaceID) 230 230 } 231 231 ) 232 - let cliRouter = CLICommandRouter(listHandler: listHandler, sendHandler: sendHandler) 232 + let focusHandler = FocusCommandHandler( 233 + resolveProvider: { selector in 234 + let resolver = TargetResolver { 235 + TargetResolutionSnapshotBuilder.makeSnapshot( 236 + repositoriesState: appStore.state.repositories, 237 + terminalManager: terminalManager 238 + ) 239 + } 240 + return resolver.resolve(selector).map { FocusResolvedTarget(from: $0) } 241 + }, 242 + focusPerformer: { target in 243 + selectCLIWorktreeContext( 244 + worktreeID: target.worktreeID, 245 + appStore: appStore, 246 + terminalManager: terminalManager 247 + ) 248 + guard let state = terminalManager.stateIfExists(for: target.worktreeID) else { 249 + return false 250 + } 251 + return state.focusSurface(id: target.paneID) 252 + }, 253 + bringToFront: { 254 + bringMainWindowToFront() 255 + } 256 + ) 257 + let cliRouter = CLICommandRouter( 258 + listHandler: listHandler, 259 + focusHandler: focusHandler, 260 + sendHandler: sendHandler 261 + ) 233 262 let cliServer = CLISocketServer(router: cliRouter) 234 263 let logger = SupaLogger("CLIService") 235 264 do { ··· 239 268 logger.warning("Failed to start CLI socket server: \(String(describing: error))") 240 269 } 241 270 return cliServer 271 + } 272 + 273 + private static func selectCLIWorktreeContext( 274 + worktreeID: Worktree.ID, 275 + appStore: StoreOf<AppFeature>, 276 + terminalManager: WorktreeTerminalManager 277 + ) { 278 + let repositories = appStore.state.repositories 279 + if repositories.worktree(for: worktreeID) != nil { 280 + appStore.send(.repositories(.selectWorktree(worktreeID))) 281 + } else if let repository = repositories.repositories[id: worktreeID], 282 + repository.capabilities.supportsRunnableFolderActions 283 + { 284 + appStore.send(.repositories(.selectRepository(worktreeID))) 285 + } 286 + terminalManager.handleCommand(.setSelectedWorktreeID(worktreeID)) 287 + } 288 + 289 + private static func bringMainWindowToFront() -> Bool { 290 + let app = NSApplication.shared 291 + guard let window = mainWindow(from: app) else { 292 + return false 293 + } 294 + if window.isMiniaturized { 295 + window.deminiaturize(nil) 296 + } 297 + app.activate(ignoringOtherApps: true) 298 + window.makeKeyAndOrderFront(nil) 299 + return true 300 + } 301 + 302 + private static func mainWindow(from app: NSApplication) -> NSWindow? { 303 + if let window = app.windows.first(where: { $0.identifier?.rawValue == "main" }) { 304 + return window 305 + } 306 + if let window = app.windows.first(where: { $0.identifier?.rawValue != "settings" }) { 307 + return window 308 + } 309 + return app.windows.first 242 310 } 243 311 244 312 var body: some Scene {
+183
supacode/CLIService/FocusCommandHandler.swift
··· 1 + // supacode/CLIService/FocusCommandHandler.swift 2 + // Handles `prowl focus` by resolving, focusing, and returning final pane context. 3 + 4 + import Foundation 5 + 6 + /// Resolved target metadata for focus payload construction. 7 + struct FocusResolvedTarget: Sendable { 8 + let worktreeID: String 9 + let worktreeName: String 10 + let worktreePath: String 11 + let worktreeRootPath: String 12 + let worktreeKind: ListCommandWorktree.Kind 13 + let tabID: UUID 14 + let tabTitle: String 15 + let tabSelected: Bool 16 + let paneID: UUID 17 + let paneTitle: String 18 + let paneCWD: String? 19 + let paneFocused: Bool 20 + } 21 + 22 + extension FocusResolvedTarget { 23 + init(from resolved: ResolvedTarget) { 24 + self.worktreeID = resolved.worktreeID 25 + self.worktreeName = resolved.worktreeName 26 + self.worktreePath = resolved.worktreePath 27 + self.worktreeRootPath = resolved.worktreeRootPath 28 + self.worktreeKind = resolved.worktreeKind 29 + self.tabID = resolved.tabID 30 + self.tabTitle = resolved.tabTitle 31 + self.tabSelected = resolved.tabSelected 32 + self.paneID = resolved.paneID 33 + self.paneTitle = resolved.paneTitle 34 + self.paneCWD = resolved.paneCWD 35 + self.paneFocused = resolved.paneFocused 36 + } 37 + } 38 + 39 + @MainActor 40 + final class FocusCommandHandler: CommandHandler { 41 + typealias ResolveProvider = @MainActor (TargetSelector) -> Result<FocusResolvedTarget, TargetResolverError> 42 + typealias FocusPerformer = @MainActor (FocusResolvedTarget) -> Bool 43 + typealias BringToFront = @MainActor () -> Bool 44 + 45 + private let resolveProvider: ResolveProvider 46 + private let focusPerformer: FocusPerformer 47 + private let bringToFront: BringToFront 48 + 49 + init( 50 + resolveProvider: @escaping ResolveProvider, 51 + focusPerformer: @escaping FocusPerformer, 52 + bringToFront: @escaping BringToFront 53 + ) { 54 + self.resolveProvider = resolveProvider 55 + self.focusPerformer = focusPerformer 56 + self.bringToFront = bringToFront 57 + } 58 + 59 + func handle(envelope: CommandEnvelope) -> CommandResponse { 60 + guard case .focus(let input) = envelope.command else { 61 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Invalid command.") 62 + } 63 + 64 + let requested = makeRequestedTarget(from: input.selector) 65 + let resolvedVia = makeResolvedVia(from: input.selector) 66 + 67 + // Resolve requested selector. 68 + let requestedTarget: FocusResolvedTarget 69 + switch resolveProvider(input.selector) { 70 + case .success(let target): 71 + requestedTarget = target 72 + case .failure(let error): 73 + return mapResolverError(error) 74 + } 75 + 76 + // Apply focus operation. 77 + guard focusPerformer(requestedTarget) else { 78 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Failed to focus requested target.") 79 + } 80 + 81 + // Bring app window to front. 82 + guard bringToFront() else { 83 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Failed to bring Prowl to front.") 84 + } 85 + 86 + // Always return the final active pane context for command chaining. 87 + let finalTarget: FocusResolvedTarget 88 + switch resolveProvider(.none) { 89 + case .success(let target): 90 + finalTarget = target 91 + case .failure: 92 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Focused target could not be resolved.") 93 + } 94 + 95 + // Contract invariant: successful focus must end at selected tab + focused pane. 96 + guard finalTarget.tabSelected, finalTarget.paneFocused else { 97 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Focused target was not activated.") 98 + } 99 + 100 + let payload = FocusCommandPayload( 101 + requested: requested, 102 + resolvedVia: resolvedVia, 103 + broughtToFront: true, 104 + target: makePayloadTarget(from: finalTarget) 105 + ) 106 + 107 + do { 108 + return try CommandResponse( 109 + ok: true, 110 + command: "focus", 111 + schemaVersion: "prowl.cli.focus.v1", 112 + data: RawJSON(encoding: payload) 113 + ) 114 + } catch { 115 + return errorResponse(code: CLIErrorCode.focusFailed, message: "Failed to encode response.") 116 + } 117 + } 118 + 119 + private func makeRequestedTarget(from selector: TargetSelector) -> FocusRequestedTarget { 120 + switch selector { 121 + case .worktree(let value): 122 + return FocusRequestedTarget(selector: .worktree, value: value) 123 + case .tab(let value): 124 + return FocusRequestedTarget(selector: .tab, value: value) 125 + case .pane(let value): 126 + return FocusRequestedTarget(selector: .pane, value: value) 127 + case .none: 128 + return FocusRequestedTarget(selector: .current, value: nil) 129 + } 130 + } 131 + 132 + private func makeResolvedVia(from selector: TargetSelector) -> FocusResolvedVia { 133 + switch selector { 134 + case .worktree: 135 + return .worktree 136 + case .tab: 137 + return .tab 138 + case .pane, .none: 139 + return .pane 140 + } 141 + } 142 + 143 + private func makePayloadTarget(from target: FocusResolvedTarget) -> FocusTarget { 144 + FocusTarget( 145 + worktree: FocusTargetWorktree( 146 + id: target.worktreeID, 147 + name: target.worktreeName, 148 + path: target.worktreePath, 149 + rootPath: target.worktreeRootPath, 150 + kind: target.worktreeKind.rawValue 151 + ), 152 + tab: FocusTargetTab( 153 + id: target.tabID.uuidString, 154 + title: target.tabTitle, 155 + selected: target.tabSelected 156 + ), 157 + pane: FocusTargetPane( 158 + id: target.paneID.uuidString, 159 + title: target.paneTitle, 160 + cwd: target.paneCWD, 161 + focused: target.paneFocused 162 + ) 163 + ) 164 + } 165 + 166 + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { 167 + switch error { 168 + case .notFound(let message): 169 + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) 170 + case .notUnique(let message): 171 + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) 172 + } 173 + } 174 + 175 + private func errorResponse(code: String, message: String) -> CommandResponse { 176 + CommandResponse( 177 + ok: false, 178 + command: "focus", 179 + schemaVersion: "prowl.cli.focus.v1", 180 + error: CommandError(code: code, message: message) 181 + ) 182 + } 183 + }
+115
supacode/CLIService/Shared/FocusCommandPayload.swift
··· 1 + // ProwlShared/FocusCommandPayload.swift 2 + // Success payload for `prowl focus --json` matching focus.md contract. 3 + 4 + import Foundation 5 + 6 + public struct FocusCommandPayload: Codable, Sendable, Equatable { 7 + public let requested: FocusRequestedTarget 8 + public let resolvedVia: FocusResolvedVia 9 + public let broughtToFront: Bool 10 + public let target: FocusTarget 11 + 12 + enum CodingKeys: String, CodingKey { 13 + case requested 14 + case resolvedVia = "resolved_via" 15 + case broughtToFront = "brought_to_front" 16 + case target 17 + } 18 + 19 + public init( 20 + requested: FocusRequestedTarget, 21 + resolvedVia: FocusResolvedVia, 22 + broughtToFront: Bool, 23 + target: FocusTarget 24 + ) { 25 + self.requested = requested 26 + self.resolvedVia = resolvedVia 27 + self.broughtToFront = broughtToFront 28 + self.target = target 29 + } 30 + } 31 + 32 + public struct FocusRequestedTarget: Codable, Sendable, Equatable { 33 + public let selector: FocusRequestedSelector 34 + public let value: String? 35 + 36 + public init(selector: FocusRequestedSelector, value: String?) { 37 + self.selector = selector 38 + self.value = value 39 + } 40 + } 41 + 42 + public enum FocusRequestedSelector: String, Codable, Sendable { 43 + case worktree 44 + case tab 45 + case pane 46 + case current 47 + } 48 + 49 + public enum FocusResolvedVia: String, Codable, Sendable { 50 + case worktree 51 + case tab 52 + case pane 53 + } 54 + 55 + public struct FocusTarget: Codable, Sendable, Equatable { 56 + public let worktree: FocusTargetWorktree 57 + public let tab: FocusTargetTab 58 + public let pane: FocusTargetPane 59 + 60 + public init(worktree: FocusTargetWorktree, tab: FocusTargetTab, pane: FocusTargetPane) { 61 + self.worktree = worktree 62 + self.tab = tab 63 + self.pane = pane 64 + } 65 + } 66 + 67 + public struct FocusTargetWorktree: Codable, Sendable, Equatable { 68 + public let id: String 69 + public let name: String 70 + public let path: String 71 + public let rootPath: String 72 + public let kind: String 73 + 74 + enum CodingKeys: String, CodingKey { 75 + case id 76 + case name 77 + case path 78 + case rootPath = "root_path" 79 + case kind 80 + } 81 + 82 + public init(id: String, name: String, path: String, rootPath: String, kind: String) { 83 + self.id = id 84 + self.name = name 85 + self.path = path 86 + self.rootPath = rootPath 87 + self.kind = kind 88 + } 89 + } 90 + 91 + public struct FocusTargetTab: Codable, Sendable, Equatable { 92 + public let id: String 93 + public let title: String 94 + public let selected: Bool 95 + 96 + public init(id: String, title: String, selected: Bool) { 97 + self.id = id 98 + self.title = title 99 + self.selected = selected 100 + } 101 + } 102 + 103 + public struct FocusTargetPane: Codable, Sendable, Equatable { 104 + public let id: String 105 + public let title: String 106 + public let cwd: String? 107 + public let focused: Bool 108 + 109 + public init(id: String, title: String, cwd: String?, focused: Bool) { 110 + self.id = id 111 + self.title = title 112 + self.cwd = cwd 113 + self.focused = focused 114 + } 115 + }
+196
supacodeTests/CLIFocusCommandHandlerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CLIFocusCommandHandlerTests { 8 + private static let paneID = UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")! 9 + private static let tabID = UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")! 10 + 11 + private static func makeTarget( 12 + tabSelected: Bool = true, 13 + paneFocused: Bool = true 14 + ) -> FocusResolvedTarget { 15 + FocusResolvedTarget( 16 + worktreeID: "Prowl:/Users/onevcat/Projects/Prowl", 17 + worktreeName: "Prowl", 18 + worktreePath: "/Users/onevcat/Projects/Prowl", 19 + worktreeRootPath: "/Users/onevcat/Projects/Prowl", 20 + worktreeKind: .git, 21 + tabID: tabID, 22 + tabTitle: "Prowl 1", 23 + tabSelected: tabSelected, 24 + paneID: paneID, 25 + paneTitle: "zsh", 26 + paneCWD: "/Users/onevcat/Projects/Prowl", 27 + paneFocused: paneFocused 28 + ) 29 + } 30 + 31 + @Test func successfulFocusReturnsSchemaConformantPayload() async throws { 32 + var focusedTarget: FocusResolvedTarget? 33 + let handler = FocusCommandHandler( 34 + resolveProvider: { selector in 35 + switch selector { 36 + case .pane(let paneID): 37 + #expect(paneID == Self.paneID.uuidString) 38 + return .success(Self.makeTarget(tabSelected: false, paneFocused: false)) 39 + case .none: 40 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 41 + default: 42 + Issue.record("Unexpected selector: \(selector)") 43 + return .failure(.notFound("Unexpected selector")) 44 + } 45 + }, 46 + focusPerformer: { target in 47 + focusedTarget = target 48 + return true 49 + }, 50 + bringToFront: { true } 51 + ) 52 + 53 + let response = await handler.handle( 54 + envelope: CommandEnvelope( 55 + output: .json, 56 + command: .focus(FocusInput(selector: .pane(Self.paneID.uuidString))) 57 + ) 58 + ) 59 + 60 + #expect(response.ok) 61 + #expect(response.command == "focus") 62 + #expect(response.schemaVersion == "prowl.cli.focus.v1") 63 + #expect(focusedTarget?.paneID == Self.paneID) 64 + 65 + let payload = try #require(try response.data?.decode(as: FocusCommandPayload.self)) 66 + #expect(payload.requested.selector == .pane) 67 + #expect(payload.requested.value == Self.paneID.uuidString) 68 + #expect(payload.resolvedVia == .pane) 69 + #expect(payload.broughtToFront == true) 70 + #expect(payload.target.worktree.id == "Prowl:/Users/onevcat/Projects/Prowl") 71 + #expect(payload.target.tab.id == Self.tabID.uuidString) 72 + #expect(payload.target.tab.selected == true) 73 + #expect(payload.target.pane.id == Self.paneID.uuidString) 74 + #expect(payload.target.pane.focused == true) 75 + } 76 + 77 + @Test func currentSelectorUsesNilRequestedValue() async throws { 78 + var resolveNoneCount = 0 79 + let handler = FocusCommandHandler( 80 + resolveProvider: { selector in 81 + #expect(selector == .none) 82 + resolveNoneCount += 1 83 + if resolveNoneCount == 1 { 84 + return .success(Self.makeTarget(tabSelected: false, paneFocused: false)) 85 + } 86 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 87 + }, 88 + focusPerformer: { _ in true }, 89 + bringToFront: { true } 90 + ) 91 + 92 + let response = await handler.handle( 93 + envelope: CommandEnvelope(output: .json, command: .focus(FocusInput(selector: .none))) 94 + ) 95 + 96 + #expect(response.ok) 97 + #expect(resolveNoneCount == 2) 98 + let payload = try #require(try response.data?.decode(as: FocusCommandPayload.self)) 99 + #expect(payload.requested.selector == .current) 100 + #expect(payload.requested.value == nil) 101 + #expect(payload.resolvedVia == .pane) 102 + } 103 + 104 + @Test func targetNotFoundMapsToContractCode() async { 105 + let handler = FocusCommandHandler( 106 + resolveProvider: { _ in .failure(.notFound("Pane missing")) }, 107 + focusPerformer: { _ in true }, 108 + bringToFront: { true } 109 + ) 110 + 111 + let response = await handler.handle( 112 + envelope: CommandEnvelope( 113 + output: .json, 114 + command: .focus(FocusInput(selector: .pane("missing"))) 115 + ) 116 + ) 117 + 118 + #expect(response.ok == false) 119 + #expect(response.error?.code == CLIErrorCode.targetNotFound) 120 + } 121 + 122 + @Test func targetNotUniqueMapsToContractCode() async { 123 + let handler = FocusCommandHandler( 124 + resolveProvider: { _ in .failure(.notUnique("Ambiguous worktree")) }, 125 + focusPerformer: { _ in true }, 126 + bringToFront: { true } 127 + ) 128 + 129 + let response = await handler.handle( 130 + envelope: CommandEnvelope( 131 + output: .json, 132 + command: .focus(FocusInput(selector: .worktree("Prowl"))) 133 + ) 134 + ) 135 + 136 + #expect(response.ok == false) 137 + #expect(response.error?.code == CLIErrorCode.targetNotUnique) 138 + } 139 + 140 + @Test func focusFailureReturnsFocusFailedCode() async { 141 + let handler = FocusCommandHandler( 142 + resolveProvider: { _ in .success(Self.makeTarget()) }, 143 + focusPerformer: { _ in false }, 144 + bringToFront: { true } 145 + ) 146 + 147 + let response = await handler.handle( 148 + envelope: CommandEnvelope(output: .json, command: .focus(FocusInput(selector: .none))) 149 + ) 150 + 151 + #expect(response.ok == false) 152 + #expect(response.error?.code == CLIErrorCode.focusFailed) 153 + } 154 + 155 + @Test func bringToFrontFailureReturnsFocusFailedCode() async { 156 + let handler = FocusCommandHandler( 157 + resolveProvider: { _ in .success(Self.makeTarget()) }, 158 + focusPerformer: { _ in true }, 159 + bringToFront: { false } 160 + ) 161 + 162 + let response = await handler.handle( 163 + envelope: CommandEnvelope(output: .json, command: .focus(FocusInput(selector: .none))) 164 + ) 165 + 166 + #expect(response.ok == false) 167 + #expect(response.error?.code == CLIErrorCode.focusFailed) 168 + } 169 + 170 + @Test func finalTargetMustBeActiveOrFocusFails() async { 171 + var resolveNoneCount = 0 172 + let handler = FocusCommandHandler( 173 + resolveProvider: { selector in 174 + switch selector { 175 + case .none: 176 + resolveNoneCount += 1 177 + if resolveNoneCount == 1 { 178 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 179 + } 180 + return .success(Self.makeTarget(tabSelected: false, paneFocused: false)) 181 + default: 182 + return .failure(.notFound("Unexpected selector")) 183 + } 184 + }, 185 + focusPerformer: { _ in true }, 186 + bringToFront: { true } 187 + ) 188 + 189 + let response = await handler.handle( 190 + envelope: CommandEnvelope(output: .json, command: .focus(FocusInput(selector: .none))) 191 + ) 192 + 193 + #expect(response.ok == false) 194 + #expect(response.error?.code == CLIErrorCode.focusFailed) 195 + } 196 + }