Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Tests/blitz_tests/TerminalLauncherDuplicateLaunchTests.swift
Original file line number Diff line number Diff line change
@@ -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()))
}
49 changes: 44 additions & 5 deletions src/services/TerminalLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: "\\\\")
Expand Down