diff --git a/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift b/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift new file mode 100644 index 0000000..330cfa2 --- /dev/null +++ b/Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import Blitz + +// One test so the shared launch record isn't touched by parallel tests. +@Test func testDuplicateAgentLaunchGuard() { + let command = "claude 'dup-guard \(UUID().uuidString)'" + + #expect(TerminalLauncher.claimLaunch(of: command, now: Date())) + TerminalLauncher.finishLaunch(of: command, succeeded: true) + + // A queued second click replayed right after the launch returns is skipped. + #expect(!TerminalLauncher.claimLaunch(of: command, now: Date())) + + // The same command is allowed again once the window has passed. + #expect(TerminalLauncher.claimLaunch(of: command, now: Date().addingTimeInterval(4))) + + // A failed launch is forgotten, so an immediate retry goes through. + TerminalLauncher.finishLaunch(of: command, succeeded: false) + #expect(TerminalLauncher.claimLaunch(of: command, now: Date())) + TerminalLauncher.finishLaunch(of: command, succeeded: true) + + // A different command is never blocked by the last one. + #expect(TerminalLauncher.claimLaunch(of: command + " other", now: Date())) +} diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 562916d..2d65bb0 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -3,6 +3,14 @@ import CoreServices /// Launches the user's configured terminal with an AI agent CLI command. enum TerminalLauncher { + /// Identical launches inside this window are treated as one (e.g. a double-click + /// on a "Setup with AI" button). The osascript launch blocks the main thread, so a + /// second click is queued and replayed right after the first launch returns — + /// without this guard it opens a second terminal running the same agent prompt. + private static let duplicateLaunchWindow: TimeInterval = 3 + private static let lastLaunchLock = NSLock() + private static var lastLaunch: (command: String, date: Date)? + static func buildAgentCommand( projectPath: String?, agent: AIAgent, @@ -68,18 +76,30 @@ enum TerminalLauncher { skipPermissions: skipPermissions ) + if case .builtIn = terminal.resolvedFallback { + return false // Handled by ContentView directly + } + guard claimLaunch(of: shellCommand) else { + return true // Same agent session was just launched; don't open it twice. + } + let launched: Bool switch terminal.resolvedFallback { case .builtIn: - return false // Handled by ContentView directly + launched = false case .terminal: - return launchTerminalApp(command: shellCommand) + launched = launchTerminalApp(command: shellCommand) case .ghostty: - return launchGhostty(command: shellCommand) + launched = launchGhostty(command: shellCommand) case .iterm: - return launchITerm(command: shellCommand) + launched = launchITerm(command: shellCommand) case .custom(let path): - return launchCustom(appPath: path, command: shellCommand) + launched = launchCustom(appPath: path, command: shellCommand) } + // Measure the window from when the (blocking) launch finishes, so a click + // queued behind a slow osascript call is still recognised as a duplicate. + // A failed launch is forgotten so an immediate retry isn't swallowed. + finishLaunch(of: shellCommand, succeeded: launched) + return launched } /// Launch using the settings from SettingsService @@ -191,6 +211,25 @@ enum TerminalLauncher { // MARK: - Helpers + /// Returns false if the identical command was launched within `duplicateLaunchWindow`. + static func claimLaunch(of command: String, now: Date = Date()) -> Bool { + lastLaunchLock.lock() + defer { lastLaunchLock.unlock() } + if let last = lastLaunch, + last.command == command, + now.timeIntervalSince(last.date) < duplicateLaunchWindow { + return false + } + lastLaunch = (command, now) + return true + } + + static func finishLaunch(of command: String, succeeded: Bool) { + lastLaunchLock.lock() + defer { lastLaunchLock.unlock() } + lastLaunch = succeeded ? (command, Date()) : nil + } + private static func escapeForAppleScript(_ command: String) -> String { command .replacingOccurrences(of: "\\", with: "\\\\")