native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #129 from onevcat/onevclaw/issue-107-cli-list-runtime

feat(cli): implement list v1 runtime via command service

authored by

Wei Wang and committed by
GitHub
4056d4e0 2aa9b805

+953 -13
+4
AGENTS.md
··· 13 13 make check # Run both format and lint 14 14 make test # Run all tests 15 15 make log-stream # Stream app logs (subsystem: com.onevcat.prowl) 16 + make build-cli # Build CLI (prowl) via SwiftPM 17 + make test-cli-smoke # Run CLI smoke tests (unit-level) 18 + make test-cli-integration # Run CLI integration tests (socket round-trip) 16 19 make bump-version # Bump version (date-based YYYY.M.DD) and create git tag 17 20 make bump-and-release # Bump version and push to trigger release 18 21 ``` ··· 113 116 ## Rules 114 117 115 118 - After a task, ensure the app builds: `make build-app` 119 + - When working on CLI code (`ProwlCLI/`, `ProwlCLITests/`, `Package.swift`), run `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` before committing. 116 120 - Automatically commit your changes and your changes only. Do not use `git add .` 117 121 - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch 118 122 - After implementing an execplan, always submit a PR if you're not in the main branch
+10 -1
Package.resolved
··· 1 1 { 2 - "originHash" : "2df18def4fd8abdae1d0f5aeeda482240785c69802373a91637b7fa48e11e3ad", 2 + "originHash" : "6a83eb824bb8cc817831017cf72468c234a2d99470f15d658e3a30b8bddccd4c", 3 3 "pins" : [ 4 + { 5 + "identity" : "rainbow", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/onevcat/Rainbow", 8 + "state" : { 9 + "revision" : "cdf146ae671b2624917648b61c908d1244b98ca1", 10 + "version" : "4.2.1" 11 + } 12 + }, 4 13 { 5 14 "identity" : "swift-argument-parser", 6 15 "kind" : "remoteSourceControl",
+2
Package.swift
··· 19 19 ], 20 20 dependencies: [ 21 21 .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 22 + .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0"), 22 23 ], 23 24 targets: [ 24 25 .target( ··· 30 31 dependencies: [ 31 32 "ProwlCLIShared", 32 33 .product(name: "ArgumentParser", package: "swift-argument-parser"), 34 + .product(name: "Rainbow", package: "Rainbow"), 33 35 ], 34 36 path: "ProwlCLI" 35 37 ),
+3 -1
ProwlCLI/CLIExecution.swift
··· 3 3 4 4 import ArgumentParser 5 5 import ProwlCLIShared 6 + @preconcurrency import Rainbow 6 7 7 8 enum CLIExecution { 8 - static func run(command: String, output: OutputMode, _ body: () throws -> Void) throws { 9 + static func run(command: String, output: OutputMode, colorEnabled: Bool = true, _ body: () throws -> Void) throws { 10 + Rainbow.enabled = colorEnabled 9 11 do { 10 12 try body() 11 13 } catch let error as ExitError {
+7
ProwlCLI/Commands/GlobalOptions.swift
··· 8 8 @Flag(name: .long, help: "Output in JSON format matching schema contracts.") 9 9 var json = false 10 10 11 + @Flag(name: .long, help: "Disable colored output.") 12 + var noColor = false 13 + 11 14 var outputMode: OutputMode { 12 15 json ? .json : .text 16 + } 17 + 18 + var colorEnabled: Bool { 19 + !noColor && !json 13 20 } 14 21 }
+1 -1
ProwlCLI/Commands/ListCommand.swift
··· 12 12 @OptionGroup var options: GlobalOptions 13 13 14 14 mutating func run() throws { 15 - try CLIExecution.run(command: "list", output: options.outputMode) { 15 + try CLIExecution.run(command: "list", output: options.outputMode, colorEnabled: options.colorEnabled) { 16 16 let envelope = CommandEnvelope( 17 17 output: options.outputMode, 18 18 command: .list(ListInput())
+105 -1
ProwlCLI/Output/OutputRenderer.swift
··· 3 3 4 4 import Foundation 5 5 import ProwlCLIShared 6 + import Rainbow 6 7 7 8 enum OutputRenderer { 8 9 static func render(_ response: CommandResponse, mode: OutputMode) { ··· 40 41 41 42 private static func renderText(_ response: CommandResponse) { 42 43 if response.ok { 44 + if response.command == "list", 45 + let data = response.data, 46 + let payload = try? data.decode(as: ListCommandPayload.self) 47 + { 48 + print(renderList(payload)) 49 + return 50 + } 51 + 43 52 print("ok: \(response.command)") 44 - } else if let error = response.error { 53 + return 54 + } 55 + 56 + if let error = response.error { 45 57 FileHandle.standardError.write( 46 58 Data("error [\(error.code)]: \(error.message)\n".utf8) 47 59 ) 48 60 } 61 + } 62 + 63 + private static func renderList(_ payload: ListCommandPayload) -> String { 64 + guard !payload.items.isEmpty else { 65 + return "No panes found." 66 + } 67 + 68 + // Group items by worktree, preserving order of first appearance. 69 + var worktreeOrder: [String] = [] 70 + var worktreeGroups: [String: [ListCommandItem]] = [:] 71 + for item in payload.items { 72 + let key = item.worktree.id 73 + if worktreeGroups[key] == nil { 74 + worktreeOrder.append(key) 75 + } 76 + worktreeGroups[key, default: []].append(item) 77 + } 78 + 79 + var lines: [String] = [] 80 + 81 + for (index, worktreeID) in worktreeOrder.enumerated() { 82 + guard let items = worktreeGroups[worktreeID], let first = items.first else { continue } 83 + 84 + if index > 0 { 85 + lines.append("") 86 + } 87 + 88 + // Worktree header: "ProjectName:branch (status)" 89 + let projectName = projectName(from: first.worktree.path) 90 + let statusText: String 91 + switch first.task.status { 92 + case .running: 93 + statusText = "running".green 94 + case .idle: 95 + statusText = "idle".dim 96 + case nil: 97 + statusText = "n/a".dim 98 + } 99 + lines.append( 100 + "\(projectName.cyan.bold)\(":".dim)\(first.worktree.name) (\(statusText)) \(first.worktree.id.dim)" 101 + ) 102 + lines.append(" \("path:".dim) \(first.worktree.path)") 103 + 104 + // Group panes by tab within this worktree. 105 + var tabOrder: [String] = [] 106 + var tabGroups: [String: [ListCommandItem]] = [:] 107 + for item in items { 108 + let tabKey = item.tab.id 109 + if tabGroups[tabKey] == nil { 110 + tabOrder.append(tabKey) 111 + } 112 + tabGroups[tabKey, default: []].append(item) 113 + } 114 + 115 + let worktreePath = normalizeTrailingSlash(first.worktree.path) 116 + 117 + for (tabIndex, tabID) in tabOrder.enumerated() { 118 + guard let tabItems = tabGroups[tabID], let firstTab = tabItems.first else { continue } 119 + 120 + let tabNum = "Tab \(tabIndex + 1):" 121 + let selectedMark = firstTab.tab.selected ? "*".yellow : " " 122 + let tabTitle = firstTab.tab.selected ? firstTab.tab.title.yellow : firstTab.tab.title 123 + lines.append(" [\(selectedMark)] \(tabNum.dim) \(tabTitle)") 124 + 125 + for (paneIndex, item) in tabItems.enumerated() { 126 + let focusMark = item.pane.focused ? ">".green.bold : " " 127 + let paneNum = item.pane.focused ? "Pane \(paneIndex + 1):".green : "Pane \(paneIndex + 1):".dim 128 + let paneTitle = item.pane.focused ? item.pane.title.green.bold : item.pane.title.dim 129 + 130 + var paneLine = " \(focusMark) \(paneNum) \(paneTitle)" 131 + 132 + // Only show cwd when it differs from the worktree path. 133 + if let cwd = item.pane.cwd, normalizeTrailingSlash(cwd) != worktreePath { 134 + paneLine += " \(cwd.dim)" 135 + } 136 + 137 + paneLine += " \(item.pane.id.dim)" 138 + lines.append(paneLine) 139 + } 140 + } 141 + } 142 + 143 + return lines.joined(separator: "\n") 144 + } 145 + 146 + private static func projectName(from path: String) -> String { 147 + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path 148 + return trimmed.split(separator: "/").last.map(String.init) ?? path 149 + } 150 + 151 + private static func normalizeTrailingSlash(_ path: String) -> String { 152 + path.hasSuffix("/") ? String(path.dropLast()) : path 49 153 } 50 154 }
+301
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 100 100 XCTAssertEqual(payload["command"] as? String, "focus") 101 101 } 102 102 103 + 104 + func testListCommandTextRenderingFromSocket() throws { 105 + let socketPath = temporarySocketPath(suffix: "list-text") 106 + let response = try CommandResponse( 107 + ok: true, 108 + command: "list", 109 + schemaVersion: "prowl.cli.list.v1", 110 + data: RawJSON(encoding: ListResponseData( 111 + count: 1, 112 + items: [ 113 + ListResponseItem( 114 + worktree: ListWorktree( 115 + id: "Prowl:/Users/onevcat/Projects/Prowl", 116 + name: "Prowl", 117 + path: "/Users/onevcat/Projects/Prowl", 118 + rootPath: "/Users/onevcat/Projects/Prowl", 119 + kind: "git" 120 + ), 121 + tab: ListTab( 122 + id: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", 123 + title: "Prowl 1", 124 + selected: true 125 + ), 126 + pane: ListPane( 127 + id: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", 128 + title: "zsh", 129 + cwd: "/Users/onevcat/Projects/Prowl", 130 + focused: true 131 + ), 132 + task: ListTask(status: "running") 133 + ) 134 + ] 135 + )) 136 + ) 137 + 138 + let (_, result) = try runWithMockServer( 139 + socketPath: socketPath, 140 + response: response, 141 + args: ["list"] 142 + ) 143 + 144 + XCTAssertEqual(result.exitCode, 0) 145 + XCTAssertTrue(result.stdout.contains("Prowl:Prowl (running)"), "Missing worktree header: \(result.stdout)") 146 + XCTAssertTrue(result.stdout.contains("Tab 1:"), "Missing tab label: \(result.stdout)") 147 + XCTAssertTrue(result.stdout.contains("Pane 1:"), "Missing pane label: \(result.stdout)") 148 + XCTAssertTrue( 149 + result.stdout.contains("6E1A2A10-D99F-4E3F-920C-D93AA3C05764"), 150 + "Missing pane ID: \(result.stdout)" 151 + ) 152 + } 153 + 154 + func testListEmptyPayloadShowsNoPanesFound() throws { 155 + let socketPath = temporarySocketPath(suffix: "list-empty") 156 + let response = try CommandResponse( 157 + ok: true, 158 + command: "list", 159 + schemaVersion: "prowl.cli.list.v1", 160 + data: RawJSON(encoding: ListResponseData(count: 0, items: [])) 161 + ) 162 + 163 + let (_, result) = try runWithMockServer( 164 + socketPath: socketPath, 165 + response: response, 166 + args: ["list"] 167 + ) 168 + 169 + XCTAssertEqual(result.exitCode, 0) 170 + XCTAssertTrue(result.stdout.contains("No panes found."), "Expected empty message: \(result.stdout)") 171 + } 172 + 173 + func testListMultipleWorktreesGroupedWithBlankLine() throws { 174 + let socketPath = temporarySocketPath(suffix: "list-multi-wt") 175 + let response = try CommandResponse( 176 + ok: true, 177 + command: "list", 178 + schemaVersion: "prowl.cli.list.v1", 179 + data: RawJSON(encoding: ListResponseData( 180 + count: 2, 181 + items: [ 182 + ListResponseItem( 183 + worktree: ListWorktree( 184 + id: "wt-1", name: "main", 185 + path: "/Projects/Alpha", rootPath: "/Projects/Alpha", kind: "git" 186 + ), 187 + tab: ListTab(id: "t1", title: "Tab A", selected: true), 188 + pane: ListPane(id: "p1", title: "zsh", cwd: "/Projects/Alpha", focused: true), 189 + task: ListTask(status: "running") 190 + ), 191 + ListResponseItem( 192 + worktree: ListWorktree( 193 + id: "wt-2", name: "develop", 194 + path: "/Projects/Beta", rootPath: "/Projects/Beta", kind: "git" 195 + ), 196 + tab: ListTab(id: "t2", title: "Tab B", selected: true), 197 + pane: ListPane(id: "p2", title: "zsh", cwd: "/Projects/Beta", focused: false), 198 + task: ListTask(status: "idle") 199 + ), 200 + ] 201 + )) 202 + ) 203 + 204 + let (_, result) = try runWithMockServer( 205 + socketPath: socketPath, 206 + response: response, 207 + args: ["list"] 208 + ) 209 + 210 + XCTAssertEqual(result.exitCode, 0) 211 + XCTAssertTrue(result.stdout.contains("Alpha:main (running)"), "Missing first worktree: \(result.stdout)") 212 + XCTAssertTrue(result.stdout.contains("Beta:develop (idle)"), "Missing second worktree: \(result.stdout)") 213 + 214 + // Worktrees should be separated by a blank line. 215 + let lines = result.stdout.components(separatedBy: "\n") 216 + let blankIndices = lines.enumerated().filter { $0.element.isEmpty }.map(\.offset) 217 + XCTAssertFalse(blankIndices.isEmpty, "Expected blank line between worktrees: \(result.stdout)") 218 + } 219 + 220 + func testListCwdSuppressedWhenMatchingWorktreePath() throws { 221 + let socketPath = temporarySocketPath(suffix: "list-cwd-dedup") 222 + let response = try CommandResponse( 223 + ok: true, 224 + command: "list", 225 + schemaVersion: "prowl.cli.list.v1", 226 + data: RawJSON(encoding: ListResponseData( 227 + count: 2, 228 + items: [ 229 + ListResponseItem( 230 + worktree: ListWorktree( 231 + id: "wt-1", name: "main", 232 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 233 + ), 234 + tab: ListTab(id: "t1", title: "Tab 1", selected: true), 235 + pane: ListPane(id: "p-same", title: "zsh", cwd: "/Projects/App", focused: true), 236 + task: ListTask(status: "idle") 237 + ), 238 + ListResponseItem( 239 + worktree: ListWorktree( 240 + id: "wt-1", name: "main", 241 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 242 + ), 243 + tab: ListTab(id: "t1", title: "Tab 1", selected: true), 244 + pane: ListPane(id: "p-diff", title: "zsh", cwd: "/Users/onevcat", focused: false), 245 + task: ListTask(status: "idle") 246 + ), 247 + ] 248 + )) 249 + ) 250 + 251 + let (_, result) = try runWithMockServer( 252 + socketPath: socketPath, 253 + response: response, 254 + args: ["list"] 255 + ) 256 + 257 + XCTAssertEqual(result.exitCode, 0) 258 + let lines = result.stdout.components(separatedBy: "\n") 259 + 260 + // Pane whose cwd matches worktree path should NOT repeat the cwd. 261 + let sameLine = lines.first { $0.contains("p-same") } 262 + XCTAssertNotNil(sameLine, "Missing same-cwd pane") 263 + XCTAssertFalse(sameLine?.contains("/Projects/App") ?? true, "cwd should be suppressed: \(sameLine ?? "")") 264 + 265 + // Pane whose cwd differs should show it. 266 + let diffLine = lines.first { $0.contains("p-diff") } 267 + XCTAssertNotNil(diffLine, "Missing diff-cwd pane") 268 + XCTAssertTrue(diffLine?.contains("/Users/onevcat") ?? false, "cwd should be shown: \(diffLine ?? "")") 269 + } 270 + 271 + func testListMultiTabMultiPaneNumbering() throws { 272 + let socketPath = temporarySocketPath(suffix: "list-numbering") 273 + let response = try CommandResponse( 274 + ok: true, 275 + command: "list", 276 + schemaVersion: "prowl.cli.list.v1", 277 + data: RawJSON(encoding: ListResponseData( 278 + count: 3, 279 + items: [ 280 + ListResponseItem( 281 + worktree: ListWorktree( 282 + id: "wt-1", name: "main", 283 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 284 + ), 285 + tab: ListTab(id: "tab-a", title: "Tab A", selected: false), 286 + pane: ListPane(id: "pa1", title: "zsh", cwd: "/Projects/App", focused: false), 287 + task: ListTask(status: "idle") 288 + ), 289 + ListResponseItem( 290 + worktree: ListWorktree( 291 + id: "wt-1", name: "main", 292 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 293 + ), 294 + tab: ListTab(id: "tab-b", title: "Tab B", selected: true), 295 + pane: ListPane(id: "pb1", title: "vim", cwd: "/Projects/App", focused: true), 296 + task: ListTask(status: "idle") 297 + ), 298 + ListResponseItem( 299 + worktree: ListWorktree( 300 + id: "wt-1", name: "main", 301 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 302 + ), 303 + tab: ListTab(id: "tab-b", title: "Tab B", selected: true), 304 + pane: ListPane(id: "pb2", title: "htop", cwd: "/Projects/App", focused: false), 305 + task: ListTask(status: "idle") 306 + ), 307 + ] 308 + )) 309 + ) 310 + 311 + let (_, result) = try runWithMockServer( 312 + socketPath: socketPath, 313 + response: response, 314 + args: ["list"] 315 + ) 316 + 317 + XCTAssertEqual(result.exitCode, 0) 318 + XCTAssertTrue(result.stdout.contains("Tab 1:"), "Missing Tab 1: \(result.stdout)") 319 + XCTAssertTrue(result.stdout.contains("Tab 2:"), "Missing Tab 2: \(result.stdout)") 320 + XCTAssertTrue(result.stdout.contains("Pane 1:"), "Missing Pane 1: \(result.stdout)") 321 + XCTAssertTrue(result.stdout.contains("Pane 2:"), "Missing Pane 2: \(result.stdout)") 322 + } 323 + 324 + func testListNoColorFlagProducesCleanOutput() throws { 325 + let socketPath = temporarySocketPath(suffix: "list-no-color") 326 + let response = try CommandResponse( 327 + ok: true, 328 + command: "list", 329 + schemaVersion: "prowl.cli.list.v1", 330 + data: RawJSON(encoding: ListResponseData( 331 + count: 1, 332 + items: [ 333 + ListResponseItem( 334 + worktree: ListWorktree( 335 + id: "wt-1", name: "main", 336 + path: "/Projects/App", rootPath: "/Projects/App", kind: "git" 337 + ), 338 + tab: ListTab(id: "t1", title: "Tab A", selected: true), 339 + pane: ListPane(id: "p1", title: "zsh", cwd: "/Projects/App", focused: true), 340 + task: ListTask(status: "running") 341 + ), 342 + ] 343 + )) 344 + ) 345 + 346 + let (_, result) = try runWithMockServer( 347 + socketPath: socketPath, 348 + response: response, 349 + args: ["list", "--no-color"] 350 + ) 351 + 352 + XCTAssertEqual(result.exitCode, 0) 353 + XCTAssertFalse(result.stdout.contains("\u{1B}["), "Should not contain ANSI escape codes: \(result.stdout)") 354 + XCTAssertTrue(result.stdout.contains("App:main (running)"), "Missing header: \(result.stdout)") 355 + } 356 + 103 357 // MARK: - Helpers 104 358 105 359 private func runWithMockServer( ··· 207 461 } 208 462 209 463 let broughtToFront: Bool 464 + } 465 + 466 + 467 + private struct ListResponseData: Encodable { 468 + let count: Int 469 + let items: [ListResponseItem] 470 + } 471 + 472 + private struct ListResponseItem: Encodable { 473 + let worktree: ListWorktree 474 + let tab: ListTab 475 + let pane: ListPane 476 + let task: ListTask 477 + } 478 + 479 + private struct ListWorktree: Encodable { 480 + let id: String 481 + let name: String 482 + let path: String 483 + 484 + enum CodingKeys: String, CodingKey { 485 + case id 486 + case name 487 + case path 488 + case rootPath = "root_path" 489 + case kind 490 + } 491 + 492 + let rootPath: String 493 + let kind: String 494 + } 495 + 496 + private struct ListTab: Encodable { 497 + let id: String 498 + let title: String 499 + let selected: Bool 500 + } 501 + 502 + private struct ListPane: Encodable { 503 + let id: String 504 + let title: String 505 + let cwd: String? 506 + let focused: Bool 507 + } 508 + 509 + private struct ListTask: Encodable { 510 + let status: String? 210 511 } 211 512 212 513 private struct CommandResult {
+23 -9
supacode/App/supacodeApp.swift
··· 176 176 } 177 177 _store = State(initialValue: appStore) 178 178 179 - let cliRouter = CLICommandRouter() 180 - let cliServer = CLISocketServer(router: cliRouter) 181 - let cliLogger = SupaLogger("CLIService") 182 - do { 183 - try cliServer.start() 184 - cliLogger.info("CLI socket server started at \(ProwlSocket.defaultPath)") 185 - } catch { 186 - cliLogger.warning("Failed to start CLI socket server: \(String(describing: error))") 187 - } 179 + let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) 188 180 _cliSocketServer = State(initialValue: cliServer) 189 181 190 182 runtime.onQuit = { [weak appStore] in ··· 198 190 ghosttyShortcuts: shortcuts, 199 191 commandKeyObserver: keyObserver 200 192 ) 193 + } 194 + 195 + private static func makeCLISocketServer( 196 + appStore: StoreOf<AppFeature>, 197 + terminalManager: WorktreeTerminalManager 198 + ) -> CLISocketServer { 199 + let listHandler = ListCommandHandler { 200 + ListRuntimeSnapshotBuilder.makeSnapshot( 201 + repositoriesState: appStore.state.repositories, 202 + terminalManager: terminalManager 203 + ) 204 + } 205 + let cliRouter = CLICommandRouter(listHandler: listHandler) 206 + let cliServer = CLISocketServer(router: cliRouter) 207 + let logger = SupaLogger("CLIService") 208 + do { 209 + try cliServer.start() 210 + logger.info("CLI socket server started at \(ProwlSocket.defaultPath)") 211 + } catch { 212 + logger.warning("Failed to start CLI socket server: \(String(describing: error))") 213 + } 214 + return cliServer 201 215 } 202 216 203 217 var body: some Scene {
+111
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 + // swiftlint:disable:next async_without_await 42 + func handle(envelope _: CommandEnvelope) async -> CommandResponse { 43 + do { 44 + let snapshot = try snapshotProvider() 45 + let payload = makePayload(from: snapshot) 46 + return try CommandResponse( 47 + ok: true, 48 + command: "list", 49 + schemaVersion: "prowl.cli.list.v1", 50 + data: RawJSON(encoding: payload) 51 + ) 52 + } catch { 53 + return CommandResponse( 54 + ok: false, 55 + command: "list", 56 + schemaVersion: "prowl.cli.list.v1", 57 + error: CommandError( 58 + code: CLIErrorCode.listFailed, 59 + message: "Failed to list panes." 60 + ) 61 + ) 62 + } 63 + } 64 + 65 + private func makePayload(from snapshot: ListRuntimeSnapshot) -> ListCommandPayload { 66 + var items: [ListCommandItem] = [] 67 + var didAssignFocusedPane = false 68 + 69 + for worktree in snapshot.worktrees { 70 + for tab in worktree.tabs { 71 + for pane in tab.panes { 72 + let isFocused = 73 + !didAssignFocusedPane 74 + && worktree.id == snapshot.focusedWorktreeID 75 + && tab.selected 76 + && tab.focusedPaneID == pane.id 77 + 78 + if isFocused { 79 + didAssignFocusedPane = true 80 + } 81 + 82 + items.append( 83 + ListCommandItem( 84 + worktree: ListCommandWorktree( 85 + id: worktree.id, 86 + name: worktree.name, 87 + path: worktree.path, 88 + rootPath: worktree.rootPath, 89 + kind: worktree.kind 90 + ), 91 + tab: ListCommandTab( 92 + id: tab.id.uuidString, 93 + title: tab.title, 94 + selected: tab.selected 95 + ), 96 + pane: ListCommandPane( 97 + id: pane.id.uuidString, 98 + title: pane.title, 99 + cwd: pane.cwd, 100 + focused: isFocused 101 + ), 102 + task: ListCommandTask(status: worktree.taskStatus) 103 + ) 104 + ) 105 + } 106 + } 107 + } 108 + 109 + return ListCommandPayload(count: items.count, items: items) 110 + } 111 + }
+114
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: [ListRuntimeSnapshot.Worktree] = orderedContexts.compactMap { context in 25 + guard let terminalSnapshot = activeSnapshots[context.id] else { 26 + return nil 27 + } 28 + 29 + let tabs: [ListRuntimeSnapshot.Tab] = 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 + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositoriesState.repositories.map { ($0.id, $0) }) 72 + 73 + for repositoryID in repositoriesState.orderedRepositoryIDs() { 74 + guard let repository = repositoriesByID[repositoryID] else { 75 + continue 76 + } 77 + 78 + if repository.capabilities.supportsWorktrees { 79 + for worktree in repositoriesState.orderedWorktrees(in: repository) { 80 + contexts.append( 81 + WorktreeContext( 82 + id: worktree.id, 83 + name: worktree.name, 84 + path: worktree.workingDirectory.path(percentEncoded: false), 85 + rootPath: worktree.repositoryRootURL.path(percentEncoded: false), 86 + kind: repository.kind == .git ? ListCommandWorktree.Kind.git : .plain 87 + ) 88 + ) 89 + } 90 + continue 91 + } 92 + 93 + if repository.capabilities.supportsRunnableFolderActions { 94 + let rootPath = repository.rootURL.path(percentEncoded: false) 95 + contexts.append( 96 + WorktreeContext( 97 + id: repository.id, 98 + name: repository.name, 99 + path: rootPath, 100 + rootPath: rootPath, 101 + kind: repository.kind == .git ? ListCommandWorktree.Kind.git : .plain 102 + ) 103 + ) 104 + } 105 + } 106 + 107 + return contexts 108 + } 109 + 110 + private static func normalizeAbsolutePath(_ value: String?) -> String? { 111 + guard let value else { return nil } 112 + return value.hasPrefix("/") ? value : nil 113 + } 114 + }
+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
··· 1456 1456 } 1457 1457 } 1458 1458 1459 + struct CLIWorktreeTerminalSnapshot: Sendable { 1460 + let tabs: [CLITerminalTabSnapshot] 1461 + let taskStatus: ListCommandTask.Status? 1462 + } 1463 + 1464 + struct CLITerminalTabSnapshot: Sendable { 1465 + let id: UUID 1466 + let title: String 1467 + let selected: Bool 1468 + let focusedPaneID: UUID? 1469 + let panes: [CLITerminalPaneSnapshot] 1470 + } 1471 + 1472 + struct CLITerminalPaneSnapshot: Sendable { 1473 + let id: UUID 1474 + let title: String 1475 + let cwd: String? 1476 + } 1477 + 1478 + extension WorktreeTerminalState { 1479 + func makeCLIListSnapshot() -> CLIWorktreeTerminalSnapshot { 1480 + let selectedTabID = tabManager.selectedTabId 1481 + 1482 + let tabs: [CLITerminalTabSnapshot] = tabManager.tabs.map { tab in 1483 + let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] 1484 + let panes = paneIDs.map { paneID in 1485 + let cwd = inheritedSurfaceConfig( 1486 + fromSurfaceId: paneID, 1487 + context: GHOSTTY_SURFACE_CONTEXT_TAB 1488 + ).workingDirectory?.path(percentEncoded: false) 1489 + 1490 + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.title) 1491 + return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) 1492 + } 1493 + 1494 + return CLITerminalTabSnapshot( 1495 + id: tab.id.rawValue, 1496 + title: tab.title, 1497 + selected: tab.id == selectedTabID, 1498 + focusedPaneID: focusedSurfaceIdByTab[tab.id], 1499 + panes: panes 1500 + ) 1501 + } 1502 + 1503 + return CLIWorktreeTerminalSnapshot( 1504 + tabs: tabs, 1505 + taskStatus: taskStatus == .running ? .running : .idle 1506 + ) 1507 + } 1508 + 1509 + private func paneTitle(surfaceID: UUID, fallbackTabTitle: String) -> String { 1510 + let rawTitle = surfaces[surfaceID]?.bridge.state.title?.trimmingCharacters( 1511 + in: .whitespacesAndNewlines 1512 + ) 1513 + 1514 + if let rawTitle, !rawTitle.isEmpty { 1515 + return rawTitle 1516 + } 1517 + 1518 + return fallbackTabTitle 1519 + } 1520 + } 1521 + 1459 1522 nonisolated func makeCommandInput( 1460 1523 script: String, 1461 1524 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(try 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 + }