From de7bf36f04eca8b86663efa8ef17f124bbe65ff6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:09:07 +0900 Subject: [PATCH] Harden CLI close commands --- ProwlCLI/Commands/PaneCommand.swift | 12 +++- ProwlCLI/Commands/TabCommand.swift | 12 +++- ProwlCLITests/ProwlCLIIntegrationTests.swift | 28 +++++++- skills/prowl-cli/SKILL.md | 6 ++ supacode/App/supacodeApp.swift | 15 ++-- supacode/CLIService/PaneCommandHandler.swift | 11 ++- supacode/CLIService/Shared/InputModels.swift | 36 +++++++++- .../CLIService/Shared/TargetSelector.swift | 5 ++ supacode/CLIService/TabCommandHandler.swift | 15 ++-- .../WorktreeTerminalState+Surfaces.swift | 28 +++++--- .../Models/WorktreeTerminalState.swift | 2 +- supacodeTests/CLICommandEnvelopeTests.swift | 68 ++++++++++++++++++- .../CLIPaneCommandHandlerTests.swift | 38 +++++++++-- supacodeTests/CLITabCommandHandlerTests.swift | 41 +++++++++-- .../WorktreeTerminalManagerTests.swift | 14 ++++ 15 files changed, 295 insertions(+), 36 deletions(-) diff --git a/ProwlCLI/Commands/PaneCommand.swift b/ProwlCLI/Commands/PaneCommand.swift index 97e43640..4fe0a065 100644 --- a/ProwlCLI/Commands/PaneCommand.swift +++ b/ProwlCLI/Commands/PaneCommand.swift @@ -22,11 +22,21 @@ struct PaneCloseCommand: ParsableCommand { @OptionGroup var selector: SelectorOptions @OptionGroup var options: GlobalOptions + @Flag(name: .long, help: "Close without prompting for protected panes.") + var force = false + mutating func run() throws { try CLIExecution.run(command: "pane", output: options.outputMode, colorEnabled: options.colorEnabled) { + let resolvedSelector = try selector.resolve() + guard !resolvedSelector.isNone else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "pane close requires an explicit target selector." + ) + } let envelope = CommandEnvelope( output: options.outputMode, - command: .pane(PaneInput(action: .close, selector: try selector.resolve())) + command: .pane(PaneInput(action: .close, selector: resolvedSelector, force: force)) ) try CLIRunner.execute(envelope) } diff --git a/ProwlCLI/Commands/TabCommand.swift b/ProwlCLI/Commands/TabCommand.swift index 070ea79f..01344502 100644 --- a/ProwlCLI/Commands/TabCommand.swift +++ b/ProwlCLI/Commands/TabCommand.swift @@ -55,11 +55,21 @@ struct TabCloseCommand: ParsableCommand { @OptionGroup var selector: SelectorOptions @OptionGroup var options: GlobalOptions + @Flag(name: .long, help: "Close without prompting for protected panes.") + var force = false + mutating func run() throws { try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { + let resolvedSelector = try selector.resolve() + guard !resolvedSelector.isNone else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "tab close requires an explicit target selector." + ) + } let envelope = CommandEnvelope( output: options.outputMode, - command: .tab(TabInput(action: .close, selector: try selector.resolve())) + command: .tab(TabInput(action: .close, selector: resolvedSelector, force: force)) ) try CLIRunner.execute(envelope) } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 8e0537a1..095c8726 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -193,7 +193,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let (requestData, result) = try runWithMockServer( socketPath: socketPath, response: response, - args: ["tab", "close", "--tab", "tab-123", "--json"] + args: ["tab", "close", "--tab", "tab-123", "--force", "--json"] ) XCTAssertEqual(result.exitCode, 0) @@ -202,11 +202,23 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(input.action, .close) XCTAssertEqual(input.selector, .tab("tab-123")) XCTAssertNil(input.path) + XCTAssertTrue(input.force) } else { XCTFail("Expected tab command envelope") } } + func testTabCloseRejectsMissingTargetBeforeTransport() throws { + let result = try runProwl(args: ["tab", "close", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "tab") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + func testPaneCloseCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "pane-close") let response = try CommandResponse( @@ -219,7 +231,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let (requestData, result) = try runWithMockServer( socketPath: socketPath, response: response, - args: ["pane", "close", "--pane", "pane-123", "--json"] + args: ["pane", "close", "--pane", "pane-123", "--force", "--json"] ) XCTAssertEqual(result.exitCode, 0) @@ -227,11 +239,23 @@ final class ProwlCLIIntegrationTests: XCTestCase { if case .pane(let input) = envelope.command { XCTAssertEqual(input.action, .close) XCTAssertEqual(input.selector, .pane("pane-123")) + XCTAssertTrue(input.force) } else { XCTFail("Expected pane command envelope") } } + func testPaneCloseRejectsMissingTargetBeforeTransport() throws { + let result = try runProwl(args: ["pane", "close", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "pane") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + func testFocusRejectsMultipleSelectorsBeforeTransport() throws { let result = try runProwl(args: ["focus", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index e0c5aabd..09ccb1a5 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -78,6 +78,12 @@ prowl pane close --pane "$pane" --json prowl tab close --tab "$tab" --json ``` +`tab close` and `pane close` require an explicit `--tab`, `--pane`, `--worktree`, or `--target`; they intentionally do not default to the currently focused pane. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: + +```bash +prowl pane close --pane "$pane" --force --json +``` + ## Reading Agent Output `task.status` is useful for coordination but is not enough to prove the screen finished rendering. `idle` can arrive before a TUI has painted its final response. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 64123ace..5394eb16 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -497,13 +497,16 @@ struct SupacodeApp: App { return nil } }, - closeTab: { target in + closeTab: { target, force in guard let tabUUID = UUID(uuidString: target.tabID), let state = terminalManager.stateIfExists(for: target.worktreeID) else { return false } - return state.closeTab(TerminalTabID(rawValue: tabUUID)) + return state.closeTab( + TerminalTabID(rawValue: tabUUID), + confirmation: force ? .skip : .prompt(.tab) + ) } ) let paneHandler = PaneCommandHandler( @@ -516,14 +519,16 @@ struct SupacodeApp: App { } return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } }, - closePane: { target in + closePane: { target, force in guard let paneID = UUID(uuidString: target.paneID), let state = terminalManager.stateIfExists(for: target.worktreeID) else { return false } - guard state.focusSurface(id: paneID) else { return false } - return state.closeFocusedSurface() + return state.closeSurface( + id: paneID, + confirmation: force ? .skip : .prompt(.pane) + ) } ) return CLICommandRouter( diff --git a/supacode/CLIService/PaneCommandHandler.swift b/supacode/CLIService/PaneCommandHandler.swift index f6d2619e..3742c86a 100644 --- a/supacode/CLIService/PaneCommandHandler.swift +++ b/supacode/CLIService/PaneCommandHandler.swift @@ -5,7 +5,7 @@ import Foundation @MainActor final class PaneCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector) -> Result - typealias ClosePaneProvider = @MainActor (TabResolvedTarget) -> Bool + typealias ClosePaneProvider = @MainActor (TabResolvedTarget, Bool) -> Bool private let resolveProvider: ResolveProvider private let closePane: ClosePaneProvider @@ -24,6 +24,13 @@ final class PaneCommandHandler: CommandHandler { return errorResponse(code: CLIErrorCode.paneFailed, message: "Invalid command.") } + if input.action == .close, input.selector.isNone { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "pane close requires an explicit target selector." + ) + } + let target: TabResolvedTarget switch resolveProvider(input.selector) { case .success(let resolved): @@ -34,7 +41,7 @@ final class PaneCommandHandler: CommandHandler { switch input.action { case .close: - guard closePane(target) else { + guard closePane(target, input.force) else { return errorResponse(code: CLIErrorCode.paneFailed, message: "Failed to close pane.") } return success(action: .close, target: target) diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 25e8e97f..c67926ca 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -143,11 +143,28 @@ public struct TabInput: Codable, Sendable { public let action: TabAction public let selector: TargetSelector public let path: String? + public let force: Bool - public init(action: TabAction, selector: TargetSelector = .none, path: String? = nil) { + enum CodingKeys: String, CodingKey { + case action + case selector + case path + case force + } + + public init(action: TabAction, selector: TargetSelector = .none, path: String? = nil, force: Bool = false) { self.action = action self.selector = selector self.path = path + self.force = force + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.action = try container.decode(TabAction.self, forKey: .action) + self.selector = try container.decode(TargetSelector.self, forKey: .selector) + self.path = try container.decodeIfPresent(String.self, forKey: .path) + self.force = try container.decodeIfPresent(Bool.self, forKey: .force) ?? false } } @@ -158,9 +175,24 @@ public enum PaneAction: String, Codable, Sendable { public struct PaneInput: Codable, Sendable { public let action: PaneAction public let selector: TargetSelector + public let force: Bool - public init(action: PaneAction, selector: TargetSelector = .none) { + enum CodingKeys: String, CodingKey { + case action + case selector + case force + } + + public init(action: PaneAction, selector: TargetSelector = .none, force: Bool = false) { self.action = action self.selector = selector + self.force = force + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.action = try container.decode(PaneAction.self, forKey: .action) + self.selector = try container.decode(TargetSelector.self, forKey: .selector) + self.force = try container.decodeIfPresent(Bool.self, forKey: .force) ?? false } } diff --git a/supacode/CLIService/Shared/TargetSelector.swift b/supacode/CLIService/Shared/TargetSelector.swift index 0cf9f43d..c6312b60 100644 --- a/supacode/CLIService/Shared/TargetSelector.swift +++ b/supacode/CLIService/Shared/TargetSelector.swift @@ -11,4 +11,9 @@ public enum TargetSelector: Codable, Sendable, Equatable { case tab(String) case pane(String) case auto(String) + + public var isNone: Bool { + if case .none = self { return true } + return false + } } diff --git a/supacode/CLIService/TabCommandHandler.swift b/supacode/CLIService/TabCommandHandler.swift index 4bf705c8..a53cb0b6 100644 --- a/supacode/CLIService/TabCommandHandler.swift +++ b/supacode/CLIService/TabCommandHandler.swift @@ -38,7 +38,7 @@ extension TabResolvedTarget { final class TabCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector) -> Result typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? - typealias CloseTabProvider = @MainActor (TabResolvedTarget) -> Bool + typealias CloseTabProvider = @MainActor (TabResolvedTarget, Bool) -> Bool private let resolveProvider: ResolveProvider private let createTab: CreateTabProvider @@ -60,6 +60,13 @@ final class TabCommandHandler: CommandHandler { return errorResponse(code: CLIErrorCode.tabFailed, message: "Invalid command.") } + if input.action == .close, input.selector.isNone { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "tab close requires an explicit target selector." + ) + } + let target: TabResolvedTarget switch resolveProvider(input.selector) { case .success(let resolved): @@ -72,7 +79,7 @@ final class TabCommandHandler: CommandHandler { case .create: return handleCreate(input: input, target: target) case .close: - return handleClose(target: target) + return handleClose(input: input, target: target) } } @@ -91,8 +98,8 @@ final class TabCommandHandler: CommandHandler { return success(action: .create, target: createdTarget) } - private func handleClose(target: TabResolvedTarget) -> CommandResponse { - guard closeTab(target) else { + private func handleClose(input: TabInput, target: TabResolvedTarget) -> CommandResponse { + guard closeTab(target, input.force) else { return errorResponse(code: CLIErrorCode.tabFailed, message: "Failed to close tab.") } return success(action: .close, target: target) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 0763c1bd..7644ac2a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -763,20 +763,31 @@ extension WorktreeTerminalState { } } - func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { - guard surfaces[view.id] != nil else { return } - if processAlive { - guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } - } + @discardableResult + func closeSurface(id surfaceID: UUID, confirmation: TerminalCloseConfirmationMode = .prompt(.pane)) -> Bool { + guard let view = surfaces[surfaceID] else { return false } + return closeSurface(view, confirmation: confirmation) + } + + @discardableResult + func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) -> Bool { + let confirmation: TerminalCloseConfirmationMode = processAlive ? .prompt(.pane) : .skip + return closeSurface(view, confirmation: confirmation) + } + + @discardableResult + private func closeSurface(_ view: GhosttySurfaceView, confirmation: TerminalCloseConfirmationMode) -> Bool { + guard surfaces[view.id] != nil else { return false } + guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: confirmation) else { return false } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() forgetSurface(view.id) - return + return true } guard let node = tree.find(id: view.id) else { view.closeSurface() forgetSurface(view.id) - return + return true } let nextSurface = focusedSurfaceIdByTab[tabId] == view.id @@ -798,7 +809,7 @@ extension WorktreeTerminalState { // Shelf's "retire the book when its last tab closes" logic // never saw this very common path. onTabClosed?() - return + return true } updateTree(newTree, for: tabId) updateRunningState(for: tabId) @@ -809,6 +820,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeValue(forKey: tabId) } } + return true } func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index f0b68355..16bd116a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -543,7 +543,7 @@ final class WorktreeTerminalState { } @discardableResult - private func closeTab(_ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode) -> Bool { + func closeTab(_ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode) -> Bool { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index 2859e175..aa6ca09d 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -139,6 +139,47 @@ struct CLICommandEnvelopeTests { #expect(input.action == .create) #expect(input.selector == .worktree("wt-main")) #expect(input.path == "/Projects/App") + #expect(input.force == false) + } else { + Issue.record("Expected .tab command") + } + } + + @Test func envelopeTabCloseForceRoundTrips() throws { + let envelope = CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .close, selector: .tab("tab-1"), force: true)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .tab(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .tab("tab-1")) + #expect(input.force == true) + } else { + Issue.record("Expected .tab command") + } + } + + @Test func envelopeTabInputDecodesMissingForceAsFalse() throws { + let json = """ + { + "output": "json", + "command": { + "tab": { + "_0": { + "action": "close", + "selector": { "tab": { "_0": "tab-1" } } + } + } + } + } + """ + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: Data(json.utf8)) + if case .tab(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .tab("tab-1")) + #expect(input.force == false) } else { Issue.record("Expected .tab command") } @@ -147,13 +188,38 @@ struct CLICommandEnvelopeTests { @Test func envelopePaneCloseRoundTrips() throws { let envelope = CommandEnvelope( output: .text, - command: .pane(PaneInput(action: .close, selector: .pane("pane-1"))) + command: .pane(PaneInput(action: .close, selector: .pane("pane-1"), force: true)) ) let data = try JSONEncoder().encode(envelope) let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) if case .pane(let input) = decoded.command { #expect(input.action == .close) #expect(input.selector == .pane("pane-1")) + #expect(input.force == true) + } else { + Issue.record("Expected .pane command") + } + } + + @Test func envelopePaneInputDecodesMissingForceAsFalse() throws { + let json = """ + { + "output": "json", + "command": { + "pane": { + "_0": { + "action": "close", + "selector": { "pane": { "_0": "pane-1" } } + } + } + } + } + """ + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: Data(json.utf8)) + if case .pane(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .pane("pane-1")) + #expect(input.force == false) } else { Issue.record("Expected .pane command") } diff --git a/supacodeTests/CLIPaneCommandHandlerTests.swift b/supacodeTests/CLIPaneCommandHandlerTests.swift index de6b595d..ea5ec3de 100644 --- a/supacodeTests/CLIPaneCommandHandlerTests.swift +++ b/supacodeTests/CLIPaneCommandHandlerTests.swift @@ -8,14 +8,16 @@ struct CLIPaneCommandHandlerTests { @Test func closeResolvesTargetAndClosesPane() async throws { let target = makeTarget(tabID: "tab-1", paneID: "pane-to-close") var closedTarget: TabResolvedTarget? + var closeForce: Bool? let handler = PaneCommandHandler( resolveProvider: { selector in #expect(selector == .pane("pane-to-close")) return .success(target) }, - closePane: { target in + closePane: { target, force in closedTarget = target + closeForce = force return true } ) @@ -23,12 +25,13 @@ struct CLIPaneCommandHandlerTests { let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .pane(PaneInput(action: .close, selector: .pane("pane-to-close"))) + command: .pane(PaneInput(action: .close, selector: .pane("pane-to-close"), force: true)) ) ) #expect(response.ok == true) #expect(closedTarget == target) + #expect(closeForce == true) let data = try #require(response.data) let payload = try data.decode(as: PaneCommandPayload.self) #expect(payload.action == .close) @@ -38,13 +41,13 @@ struct CLIPaneCommandHandlerTests { @Test func closeReturnsFailureWhenCloseActionFails() async { let handler = PaneCommandHandler( resolveProvider: { _ in .success(makeTarget()) }, - closePane: { _ in false } + closePane: { _, _ in false } ) let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .pane(PaneInput(action: .close, selector: .none)) + command: .pane(PaneInput(action: .close, selector: .pane("pane-1"))) ) ) @@ -52,6 +55,33 @@ struct CLIPaneCommandHandlerTests { #expect(response.error?.code == CLIErrorCode.paneFailed) } + @Test func closeRejectsMissingExplicitTarget() async { + var didResolve = false + var didClose = false + let handler = PaneCommandHandler( + resolveProvider: { _ in + didResolve = true + return .success(makeTarget()) + }, + closePane: { _, _ in + didClose = true + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .pane(PaneInput(action: .close, selector: .none)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(didResolve == false) + #expect(didClose == false) + } + private func makeTarget( worktreeID: String = "App:/Projects/App", worktreeName: String = "App", diff --git a/supacodeTests/CLITabCommandHandlerTests.swift b/supacodeTests/CLITabCommandHandlerTests.swift index 255e8e72..64662df0 100644 --- a/supacodeTests/CLITabCommandHandlerTests.swift +++ b/supacodeTests/CLITabCommandHandlerTests.swift @@ -22,7 +22,7 @@ struct CLITabCommandHandlerTests { createPath = path return created }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -53,7 +53,7 @@ struct CLITabCommandHandlerTests { didCreate = true return nil }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -76,7 +76,7 @@ struct CLITabCommandHandlerTests { createPath = path return makeTarget(tabID: "created-tab", paneID: "created-pane") }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -93,6 +93,7 @@ struct CLITabCommandHandlerTests { @Test func closeResolvesTargetAndClosesTab() async throws { let target = makeTarget(tabID: "tab-to-close", paneID: "pane-in-tab") var closedTarget: TabResolvedTarget? + var closeForce: Bool? let handler = TabCommandHandler( resolveProvider: { selector in @@ -100,8 +101,9 @@ struct CLITabCommandHandlerTests { return .success(target) }, createTab: { _, _ in nil }, - closeTab: { target in + closeTab: { target, force in closedTarget = target + closeForce = force return true } ) @@ -109,18 +111,47 @@ struct CLITabCommandHandlerTests { let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .tab(TabInput(action: .close, selector: .tab("tab-to-close"))) + command: .tab(TabInput(action: .close, selector: .tab("tab-to-close"), force: true)) ) ) #expect(response.ok == true) #expect(closedTarget == target) + #expect(closeForce == true) let data = try #require(response.data) let payload = try data.decode(as: TabCommandPayload.self) #expect(payload.action == .close) #expect(payload.target.tab.id == "tab-to-close") } + @Test func closeRejectsMissingExplicitTarget() async throws { + var didResolve = false + var didClose = false + let handler = TabCommandHandler( + resolveProvider: { _ in + didResolve = true + return .success(makeTarget()) + }, + createTab: { _, _ in nil }, + closeTab: { _, _ in + didClose = true + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .close, selector: .none)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(didResolve == false) + #expect(didClose == false) + } + private func makeTarget( worktreeID: String = "App:/Projects/App", worktreeName: String = "App", diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 72dfa940..c481789a 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -135,6 +135,20 @@ struct WorktreeTerminalManagerTests { #expect(state.surfaceView(for: tabId) == nil) } + @Test func closeSurfaceReturnsActualRemovalResult() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == true) + #expect(state.surfaceView(for: surfaceId) == nil) + #expect(state.tabManager.tabs.isEmpty) + #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == false) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2