native macOS codings agent orchestrator
6
fork

Configure Feed

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

feat(cli): implement list runtime via command service

onevclaw 40f0f89d da206340

+631 -2
+37 -1
ProwlCLI/Output/OutputRenderer.swift
··· 40 40 41 41 private static func renderText(_ response: CommandResponse) { 42 42 if response.ok { 43 + if response.command == "list", 44 + let data = response.data, 45 + let payload = try? data.decode(as: ListCommandPayload.self) 46 + { 47 + print(renderList(payload)) 48 + return 49 + } 50 + 43 51 print("ok: \(response.command)") 44 - } else if let error = response.error { 52 + return 53 + } 54 + 55 + if let error = response.error { 45 56 FileHandle.standardError.write( 46 57 Data("error [\(error.code)]: \(error.message)\n".utf8) 47 58 ) 48 59 } 60 + } 61 + 62 + private static func renderList(_ payload: ListCommandPayload) -> String { 63 + guard !payload.items.isEmpty else { 64 + return "No panes found." 65 + } 66 + 67 + var lines: [String] = [] 68 + lines.reserveCapacity(payload.items.count * 2) 69 + 70 + for item in payload.items { 71 + let status = item.task.status?.rawValue ?? "n/a" 72 + let focused = item.pane.focused ? "focused" : "-" 73 + lines.append( 74 + "\(item.worktree.name) | \(item.tab.title) | \(item.pane.title) | \(status) | \(focused)" 75 + ) 76 + 77 + var detail = " worktree=\(item.worktree.id) tab=\(item.tab.id) pane=\(item.pane.id)" 78 + if let cwd = item.pane.cwd { 79 + detail += " cwd=\(cwd)" 80 + } 81 + lines.append(detail) 82 + } 83 + 84 + return lines.joined(separator: "\n") 49 85 } 50 86 }
+94
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 96 96 XCTAssertEqual(payload["command"] as? String, "focus") 97 97 } 98 98 99 + 100 + func testListCommandTextRenderingFromSocket() throws { 101 + let socketPath = temporarySocketPath(suffix: "list-text") 102 + let response = try CommandResponse( 103 + ok: true, 104 + command: "list", 105 + schemaVersion: "prowl.cli.list.v1", 106 + data: RawJSON(encoding: ListResponseData( 107 + count: 1, 108 + items: [ 109 + ListResponseItem( 110 + worktree: ListWorktree( 111 + id: "Prowl:/Users/onevcat/Projects/Prowl", 112 + name: "Prowl", 113 + path: "/Users/onevcat/Projects/Prowl", 114 + rootPath: "/Users/onevcat/Projects/Prowl", 115 + kind: "git" 116 + ), 117 + tab: ListTab( 118 + id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", 119 + title: "Prowl 1", 120 + selected: true 121 + ), 122 + pane: ListPane( 123 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 124 + title: "zsh", 125 + cwd: "/Users/onevcat/Projects/Prowl", 126 + focused: true 127 + ), 128 + task: ListTask(status: "running") 129 + ) 130 + ] 131 + )) 132 + ) 133 + 134 + let (_, result) = try runWithMockServer( 135 + socketPath: socketPath, 136 + response: response, 137 + args: ["list"] 138 + ) 139 + 140 + XCTAssertEqual(result.exitCode, 0) 141 + XCTAssertTrue(result.stdout.contains("Prowl | Prowl 1 | zsh | running | focused")) 142 + XCTAssertTrue(result.stdout.contains("worktree=Prowl:/Users/onevcat/Projects/Prowl")) 143 + XCTAssertTrue(result.stdout.contains("pane=6E1A2A10-D99F-4E3F-920C-D93AA3C05764")) 144 + } 145 + 99 146 // MARK: - Helpers 100 147 101 148 private func runWithMockServer( ··· 203 250 } 204 251 205 252 let broughtToFront: Bool 253 + } 254 + 255 + 256 + private struct ListResponseData: Encodable { 257 + let count: Int 258 + let items: [ListResponseItem] 259 + } 260 + 261 + private struct ListResponseItem: Encodable { 262 + let worktree: ListWorktree 263 + let tab: ListTab 264 + let pane: ListPane 265 + let task: ListTask 266 + } 267 + 268 + private struct ListWorktree: Encodable { 269 + let id: String 270 + let name: String 271 + let path: String 272 + 273 + enum CodingKeys: String, CodingKey { 274 + case id 275 + case name 276 + case path 277 + case rootPath = "root_path" 278 + case kind 279 + } 280 + 281 + let rootPath: String 282 + let kind: String 283 + } 284 + 285 + private struct ListTab: Encodable { 286 + let id: String 287 + let title: String 288 + let selected: Bool 289 + } 290 + 291 + private struct ListPane: Encodable { 292 + let id: String 293 + let title: String 294 + let cwd: String? 295 + let focused: Bool 296 + } 297 + 298 + private struct ListTask: Encodable { 299 + let status: String? 206 300 } 207 301 208 302 private struct CommandResult {
+7 -1
supacode/App/supacodeApp.swift
··· 176 176 } 177 177 _store = State(initialValue: appStore) 178 178 179 - let cliRouter = CLICommandRouter() 179 + let listHandler = ListCommandHandler { 180 + ListRuntimeSnapshotBuilder.makeSnapshot( 181 + repositoriesState: appStore.state.repositories, 182 + terminalManager: terminalManager 183 + ) 184 + } 185 + let cliRouter = CLICommandRouter(listHandler: listHandler) 180 186 let cliServer = CLISocketServer(router: cliRouter) 181 187 let cliLogger = SupaLogger("CLIService") 182 188 do {
+108
supacode/CLIService/ListCommandHandler.swift
··· 1 + import Foundation 2 + 3 + struct ListRuntimeSnapshot: Sendable { 4 + struct Worktree: Sendable { 5 + let id: String 6 + let name: String 7 + let path: String 8 + let rootPath: String 9 + let kind: ListCommandWorktree.Kind 10 + let taskStatus: ListCommandTask.Status? 11 + let tabs: [Tab] 12 + } 13 + 14 + struct Tab: Sendable { 15 + let id: UUID 16 + let title: String 17 + let selected: Bool 18 + let focusedPaneID: UUID? 19 + let panes: [Pane] 20 + } 21 + 22 + struct Pane: Sendable { 23 + let id: UUID 24 + let title: String 25 + let cwd: String? 26 + } 27 + 28 + let worktrees: [Worktree] 29 + let focusedWorktreeID: String? 30 + } 31 + 32 + final class ListCommandHandler: CommandHandler { 33 + typealias SnapshotProvider = @MainActor () throws -> ListRuntimeSnapshot 34 + 35 + private let snapshotProvider: SnapshotProvider 36 + 37 + init(snapshotProvider: @escaping SnapshotProvider) { 38 + self.snapshotProvider = snapshotProvider 39 + } 40 + 41 + func handle(envelope _: CommandEnvelope) async -> CommandResponse { 42 + do { 43 + let snapshot = try snapshotProvider() 44 + let payload = makePayload(from: snapshot) 45 + return try CommandResponse( 46 + ok: true, 47 + command: "list", 48 + schemaVersion: "prowl.cli.list.v1", 49 + data: RawJSON(encoding: payload) 50 + ) 51 + } catch { 52 + return CommandResponse.error( 53 + command: "list", 54 + schemaVersion: "prowl.cli.list.v1", 55 + code: .listFailed, 56 + message: "Failed to list panes.", 57 + details: nil 58 + ) 59 + } 60 + } 61 + 62 + private func makePayload(from snapshot: ListRuntimeSnapshot) -> ListCommandPayload { 63 + var items: [ListCommandItem] = [] 64 + var didAssignFocusedPane = false 65 + 66 + for worktree in snapshot.worktrees { 67 + for tab in worktree.tabs { 68 + for pane in tab.panes { 69 + let isFocused = 70 + !didAssignFocusedPane 71 + && worktree.id == snapshot.focusedWorktreeID 72 + && tab.selected 73 + && tab.focusedPaneID == pane.id 74 + 75 + if isFocused { 76 + didAssignFocusedPane = true 77 + } 78 + 79 + items.append( 80 + ListCommandItem( 81 + worktree: ListCommandWorktree( 82 + id: worktree.id, 83 + name: worktree.name, 84 + path: worktree.path, 85 + rootPath: worktree.rootPath, 86 + kind: worktree.kind 87 + ), 88 + tab: ListCommandTab( 89 + id: tab.id.uuidString, 90 + title: tab.title, 91 + selected: tab.selected 92 + ), 93 + pane: ListCommandPane( 94 + id: pane.id.uuidString, 95 + title: pane.title, 96 + cwd: pane.cwd, 97 + focused: isFocused 98 + ), 99 + task: ListCommandTask(status: worktree.taskStatus) 100 + ) 101 + ) 102 + } 103 + } 104 + } 105 + 106 + return ListCommandPayload(count: items.count, items: items) 107 + } 108 + }
+113
supacode/CLIService/ListRuntimeSnapshotBuilder.swift
··· 1 + import Foundation 2 + 3 + @MainActor 4 + enum ListRuntimeSnapshotBuilder { 5 + struct WorktreeContext { 6 + let id: String 7 + let name: String 8 + let path: String 9 + let rootPath: String 10 + let kind: ListCommandWorktree.Kind 11 + } 12 + 13 + static func makeSnapshot( 14 + repositoriesState: RepositoriesFeature.State, 15 + terminalManager: WorktreeTerminalManager 16 + ) -> ListRuntimeSnapshot { 17 + let activeSnapshots = Dictionary(uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { 18 + ($0.worktreeID, $0.makeCLIListSnapshot()) 19 + }) 20 + 21 + let orderedContexts = orderedWorktreeContexts(from: repositoriesState) 22 + let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID 23 + 24 + let worktrees = orderedContexts.compactMap { context in 25 + guard let terminalSnapshot = activeSnapshots[context.id] else { 26 + return nil 27 + } 28 + 29 + let tabs = terminalSnapshot.tabs.compactMap { tabSnapshot in 30 + let panes = tabSnapshot.panes.map { paneSnapshot in 31 + ListRuntimeSnapshot.Pane( 32 + id: paneSnapshot.id, 33 + title: paneSnapshot.title, 34 + cwd: normalizeAbsolutePath(paneSnapshot.cwd) 35 + ) 36 + } 37 + 38 + guard !panes.isEmpty else { 39 + return nil 40 + } 41 + 42 + return ListRuntimeSnapshot.Tab( 43 + id: tabSnapshot.id, 44 + title: tabSnapshot.title, 45 + selected: tabSnapshot.selected, 46 + focusedPaneID: tabSnapshot.focusedPaneID, 47 + panes: panes 48 + ) 49 + } 50 + 51 + guard !tabs.isEmpty else { 52 + return nil 53 + } 54 + 55 + return ListRuntimeSnapshot.Worktree( 56 + id: context.id, 57 + name: context.name, 58 + path: context.path, 59 + rootPath: context.rootPath, 60 + kind: context.kind, 61 + taskStatus: terminalSnapshot.taskStatus, 62 + tabs: tabs 63 + ) 64 + } 65 + 66 + return ListRuntimeSnapshot(worktrees: worktrees, focusedWorktreeID: focusedWorktreeID) 67 + } 68 + 69 + private static func orderedWorktreeContexts(from repositoriesState: RepositoriesFeature.State) -> [WorktreeContext] { 70 + var contexts: [WorktreeContext] = [] 71 + 72 + for repositoryID in repositoriesState.orderedRepositoryIDs { 73 + guard let repository = repositoriesState.repositoriesByID[repositoryID] else { 74 + continue 75 + } 76 + 77 + if repository.capabilities.supportsWorktrees { 78 + for worktree in repositoriesState.orderedWorktrees(in: repository) { 79 + contexts.append( 80 + WorktreeContext( 81 + id: worktree.id, 82 + name: worktree.name, 83 + path: worktree.workingDirectory.path(percentEncoded: false), 84 + rootPath: worktree.repositoryRootURL.path(percentEncoded: false), 85 + kind: repository.kind == .git ? .git : .plain 86 + ) 87 + ) 88 + } 89 + continue 90 + } 91 + 92 + if repository.capabilities.supportsRunnableFolderActions { 93 + let rootPath = repository.rootURL.path(percentEncoded: false) 94 + contexts.append( 95 + WorktreeContext( 96 + id: repository.id, 97 + name: repository.name, 98 + path: rootPath, 99 + rootPath: rootPath, 100 + kind: repository.kind == .git ? .git : .plain 101 + ) 102 + ) 103 + } 104 + } 105 + 106 + return contexts 107 + } 108 + 109 + private static func normalizeAbsolutePath(_ value: String?) -> String? { 110 + guard let value else { return nil } 111 + return value.hasPrefix("/") ? value : nil 112 + } 113 + }
+98
supacode/CLIService/Shared/ListCommandPayload.swift
··· 1 + import Foundation 2 + 3 + public struct ListCommandPayload: Codable, Equatable { 4 + public let count: Int 5 + public let items: [ListCommandItem] 6 + 7 + public init(count: Int, items: [ListCommandItem]) { 8 + self.count = count 9 + self.items = items 10 + } 11 + } 12 + 13 + public struct ListCommandItem: Codable, Equatable { 14 + public let worktree: ListCommandWorktree 15 + public let tab: ListCommandTab 16 + public let pane: ListCommandPane 17 + public let task: ListCommandTask 18 + 19 + public init( 20 + worktree: ListCommandWorktree, 21 + tab: ListCommandTab, 22 + pane: ListCommandPane, 23 + task: ListCommandTask 24 + ) { 25 + self.worktree = worktree 26 + self.tab = tab 27 + self.pane = pane 28 + self.task = task 29 + } 30 + } 31 + 32 + public struct ListCommandWorktree: Codable, Equatable { 33 + public enum Kind: String, Codable, Equatable { 34 + case git 35 + case plain 36 + } 37 + 38 + public let id: String 39 + public let name: String 40 + public let path: String 41 + public let rootPath: String 42 + public let kind: Kind 43 + 44 + enum CodingKeys: String, CodingKey { 45 + case id 46 + case name 47 + case path 48 + case rootPath = "root_path" 49 + case kind 50 + } 51 + 52 + public init(id: String, name: String, path: String, rootPath: String, kind: Kind) { 53 + self.id = id 54 + self.name = name 55 + self.path = path 56 + self.rootPath = rootPath 57 + self.kind = kind 58 + } 59 + } 60 + 61 + public struct ListCommandTab: Codable, Equatable { 62 + public let id: String 63 + public let title: String 64 + public let selected: Bool 65 + 66 + public init(id: String, title: String, selected: Bool) { 67 + self.id = id 68 + self.title = title 69 + self.selected = selected 70 + } 71 + } 72 + 73 + public struct ListCommandPane: Codable, Equatable { 74 + public let id: String 75 + public let title: String 76 + public let cwd: String? 77 + public let focused: Bool 78 + 79 + public init(id: String, title: String, cwd: String?, focused: Bool) { 80 + self.id = id 81 + self.title = title 82 + self.cwd = cwd 83 + self.focused = focused 84 + } 85 + } 86 + 87 + public struct ListCommandTask: Codable, Equatable { 88 + public enum Status: String, Codable, Equatable { 89 + case running 90 + case idle 91 + } 92 + 93 + public let status: Status? 94 + 95 + public init(status: Status?) { 96 + self.status = status 97 + } 98 + }
+63
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 1442 1442 } 1443 1443 } 1444 1444 1445 + struct CLIWorktreeTerminalSnapshot: Sendable { 1446 + let tabs: [CLITerminalTabSnapshot] 1447 + let taskStatus: ListCommandTask.Status? 1448 + } 1449 + 1450 + struct CLITerminalTabSnapshot: Sendable { 1451 + let id: UUID 1452 + let title: String 1453 + let selected: Bool 1454 + let focusedPaneID: UUID? 1455 + let panes: [CLITerminalPaneSnapshot] 1456 + } 1457 + 1458 + struct CLITerminalPaneSnapshot: Sendable { 1459 + let id: UUID 1460 + let title: String 1461 + let cwd: String? 1462 + } 1463 + 1464 + extension WorktreeTerminalState { 1465 + func makeCLIListSnapshot() -> CLIWorktreeTerminalSnapshot { 1466 + let selectedTabID = tabManager.selectedTabId 1467 + 1468 + let tabs: [CLITerminalTabSnapshot] = tabManager.tabs.map { tab in 1469 + let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] 1470 + let panes = paneIDs.map { paneID in 1471 + let cwd = inheritedSurfaceConfig( 1472 + fromSurfaceId: paneID, 1473 + context: GHOSTTY_SURFACE_CONTEXT_TAB 1474 + ).workingDirectory?.path(percentEncoded: false) 1475 + 1476 + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.title) 1477 + return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) 1478 + } 1479 + 1480 + return CLITerminalTabSnapshot( 1481 + id: tab.id, 1482 + title: tab.title, 1483 + selected: tab.id == selectedTabID, 1484 + focusedPaneID: focusedSurfaceIdByTab[tab.id], 1485 + panes: panes 1486 + ) 1487 + } 1488 + 1489 + return CLIWorktreeTerminalSnapshot( 1490 + tabs: tabs, 1491 + taskStatus: taskStatus.map { $0 == .running ? .running : .idle } 1492 + ) 1493 + } 1494 + 1495 + private func paneTitle(surfaceID: UUID, fallbackTabTitle: String) -> String { 1496 + let rawTitle = surfaces[surfaceID]?.bridge.state.title?.trimmingCharacters( 1497 + in: .whitespacesAndNewlines 1498 + ) 1499 + 1500 + if let rawTitle, !rawTitle.isEmpty { 1501 + return rawTitle 1502 + } 1503 + 1504 + return fallbackTabTitle 1505 + } 1506 + } 1507 + 1445 1508 nonisolated func makeCommandInput( 1446 1509 script: String, 1447 1510 environmentExportPrefix: String
+111
supacodeTests/CLIListCommandHandlerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CLIListCommandHandlerTests { 8 + 9 + @Test func buildsSchemaConformantListPayloadInStableOrder() async throws { 10 + let handler = ListCommandHandler { 11 + ListRuntimeSnapshot( 12 + worktrees: [ 13 + .init( 14 + id: "Prowl:/Users/onevcat/Projects/Prowl", 15 + name: "Prowl", 16 + path: "/Users/onevcat/Projects/Prowl", 17 + rootPath: "/Users/onevcat/Projects/Prowl", 18 + kind: .git, 19 + taskStatus: .running, 20 + tabs: [ 21 + .init( 22 + id: UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")!, 23 + title: "Prowl 1", 24 + selected: true, 25 + focusedPaneID: UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")!, 26 + panes: [ 27 + .init( 28 + id: UUID(uuidString: "1344AEF5-3BA6-4B75-A07E-1F36C63A34B0")!, 29 + title: "tests", 30 + cwd: "/Users/onevcat/Projects/Prowl" 31 + ), 32 + .init( 33 + id: UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")!, 34 + title: "build", 35 + cwd: "/Users/onevcat/Projects/Prowl" 36 + ), 37 + ] 38 + ) 39 + ] 40 + ), 41 + .init( 42 + id: "Notes:/Users/onevcat/Projects/Notes", 43 + name: "Notes", 44 + path: "/Users/onevcat/Projects/Notes", 45 + rootPath: "/Users/onevcat/Projects/Notes", 46 + kind: .plain, 47 + taskStatus: .idle, 48 + tabs: [ 49 + .init( 50 + id: UUID(uuidString: "A2B07BBA-9DD0-4C77-9D76-2B3E0AF12096")!, 51 + title: "Notes", 52 + selected: true, 53 + focusedPaneID: UUID(uuidString: "EF65FF31-1B72-40B2-80DA-3AA87B7B6858")!, 54 + panes: [ 55 + .init( 56 + id: UUID(uuidString: "EF65FF31-1B72-40B2-80DA-3AA87B7B6858")!, 57 + title: "notes", 58 + cwd: "/Users/onevcat/Projects/Notes" 59 + ) 60 + ] 61 + ) 62 + ] 63 + ), 64 + ], 65 + focusedWorktreeID: "Prowl:/Users/onevcat/Projects/Prowl" 66 + ) 67 + } 68 + 69 + let response = await handler.handle( 70 + envelope: CommandEnvelope(output: .json, command: .list(ListInput())) 71 + ) 72 + 73 + #expect(response.ok) 74 + #expect(response.command == "list") 75 + #expect(response.schemaVersion == "prowl.cli.list.v1") 76 + 77 + let payload = try #require(response.data?.decode(as: ListCommandPayload.self)) 78 + #expect(payload.count == 3) 79 + #expect(payload.items.count == 3) 80 + 81 + // Stable order: worktree order -> tab order -> pane order 82 + #expect(payload.items[0].worktree.id == "Prowl:/Users/onevcat/Projects/Prowl") 83 + #expect(payload.items[0].pane.id == "1344AEF5-3BA6-4B75-A07E-1F36C63A34B0") 84 + #expect(payload.items[1].pane.id == "6E1A2A10-D99F-4E3F-920C-D93AA3C05764") 85 + #expect(payload.items[2].worktree.id == "Notes:/Users/onevcat/Projects/Notes") 86 + 87 + let focusedItems = payload.items.filter(\.pane.focused) 88 + #expect(focusedItems.count == 1) 89 + #expect(focusedItems.first?.pane.id == "6E1A2A10-D99F-4E3F-920C-D93AA3C05764") 90 + 91 + #expect(payload.items[0].task.status == .running) 92 + #expect(payload.items[2].task.status == .idle) 93 + } 94 + 95 + @Test func returnsListFailedWhenSnapshotProviderThrows() async { 96 + struct DummyError: Error {} 97 + 98 + let handler = ListCommandHandler { 99 + throw DummyError() 100 + } 101 + 102 + let response = await handler.handle( 103 + envelope: CommandEnvelope(output: .json, command: .list(ListInput())) 104 + ) 105 + 106 + #expect(response.ok == false) 107 + #expect(response.command == "list") 108 + #expect(response.schemaVersion == "prowl.cli.list.v1") 109 + #expect(response.error?.code == CLIErrorCode.listFailed) 110 + } 111 + }