diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 43d3f038..26652c14 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -8,12 +8,19 @@ struct TerminalClient { enum Command: Equatable { case createTab(Worktree, runSetupScriptIfNew: Bool) - case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, autoCloseOnSuccess: Bool) + case createTabWithInput( + Worktree, + input: String, + runSetupScriptIfNew: Bool, + autoCloseOnSuccess: Bool, + customCommandName: String? = nil + ) case createSplitWithInput( Worktree, direction: UserCustomSplitDirection, input: String, - autoCloseOnSuccess: Bool + autoCloseOnSuccess: Bool, + customCommandName: String? = nil ) case createTabInDirectory(Worktree, directory: URL) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) @@ -38,6 +45,7 @@ struct TerminalClient { } enum Event: Equatable { + case customCommandSucceeded(worktreeID: Worktree.ID, name: String, durationMs: Int) case notificationReceived(worktreeID: Worktree.ID, title: String, body: String) case notificationIndicatorChanged(count: Int) case tabCreated(worktreeID: Worktree.ID) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a0b9bf69..a8f355b4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -627,6 +627,7 @@ struct AppFeature { } let command = customCommand.command let closeOnSuccess = customCommand.closeOnSuccess + let commandName = customCommand.resolvedTitle switch customCommand.execution { case .shellScript: return .run { _ in @@ -635,7 +636,8 @@ struct AppFeature { worktree, input: command, runSetupScriptIfNew: false, - autoCloseOnSuccess: closeOnSuccess + autoCloseOnSuccess: closeOnSuccess, + customCommandName: commandName ) ) } @@ -647,7 +649,8 @@ struct AppFeature { worktree, direction: direction, input: command, - autoCloseOnSuccess: closeOnSuccess + autoCloseOnSuccess: closeOnSuccess, + customCommandName: commandName ) ) } @@ -914,6 +917,10 @@ struct AppFeature { case .commandPalette: return .none + case .terminalEvent(.customCommandSucceeded(_, let name, let durationMs)): + let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" + return .send(.repositories(.showToast(.success(message)))) + case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): var effects: [Effect] = [ .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) @@ -1009,3 +1016,17 @@ struct AppFeature { } } } + +// Renders Custom Command run duration for status toasts. +// Sub-second runs show ms; short runs show one decimal; long runs reuse the +// whole-seconds formatter used by other command-finished notifications. +func formatCustomCommandDuration(_ durationMs: Int) -> String { + if durationMs < 1_000 { + return "\(max(durationMs, 0))ms" + } + let seconds = Double(durationMs) / 1_000.0 + if seconds < 10 { + return String(format: "%.1fs", seconds) + } + return WorktreeTerminalState.formatDuration(Int(seconds)) +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 8632caa8..bb9469d1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -787,7 +787,7 @@ struct RepositoriesFeature { return .cancel(id: CancelID.toastAutoDismiss) case .success, .warning: return .run { send in - try? await ContinuousClock().sleep(for: .seconds(2.5)) + try? await ContinuousClock().sleep(for: .seconds(3)) await send(.dismissToast) } .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true) diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 0a9fc6d1..590d5ee1 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -52,22 +52,25 @@ final class WorktreeTerminalManager { switch command { case .createTab(let worktree, let runSetupScriptIfNew): Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } - case .createTabWithInput(let worktree, let input, let runSetupScriptIfNew, let autoCloseOnSuccess): + case .createTabWithInput( + let worktree, let input, let runSetupScriptIfNew, let autoCloseOnSuccess, let customCommandName): Task { createTabAsync( in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, - autoCloseOnSuccess: autoCloseOnSuccess + autoCloseOnSuccess: autoCloseOnSuccess, + customCommandName: customCommandName ) } - case .createSplitWithInput(let worktree, let direction, let input, let autoCloseOnSuccess): + case .createSplitWithInput(let worktree, let direction, let input, let autoCloseOnSuccess, let customCommandName): Task { createSplitAsync( in: worktree, direction: direction, initialInput: input, - autoCloseOnSuccess: autoCloseOnSuccess + autoCloseOnSuccess: autoCloseOnSuccess, + customCommandName: customCommandName ) } case .createTabInDirectory(let worktree, let directory): @@ -247,6 +250,9 @@ final class WorktreeTerminalManager { state.onFontSizeAdjusted = { [weak self] in self?.syncPreferredFontSize(from: worktree.id) } + state.onCustomCommandSucceeded = { [weak self] name, durationMs in + self?.emit(.customCommandSucceeded(worktreeID: worktree.id, name: name, durationMs: durationMs)) + } states[worktree.id] = state terminalLogger.info("Created terminal state for worktree \(worktree.id)") return state @@ -257,7 +263,8 @@ final class WorktreeTerminalManager { runSetupScriptIfNew: Bool, initialInput: String? = nil, workingDirectory: URL? = nil, - autoCloseOnSuccess: Bool = false + autoCloseOnSuccess: Bool = false, + customCommandName: String? = nil ) { let state = state(for: worktree) { runSetupScriptIfNew } let setupScript: String? @@ -275,8 +282,13 @@ final class WorktreeTerminalManager { initialInput: initialInput, workingDirectoryOverride: workingDirectory ) - if autoCloseOnSuccess, let tabId, let surfaceId = state.focusedSurfaceId(in: tabId) { - state.markSurfaceForAutoClose(surfaceId) + if let tabId, let surfaceId = state.focusedSurfaceId(in: tabId) { + if autoCloseOnSuccess { + state.markSurfaceForAutoClose(surfaceId) + } + if let customCommandName { + state.markSurfaceForCustomCommand(surfaceId, name: customCommandName) + } } } @@ -284,7 +296,8 @@ final class WorktreeTerminalManager { in worktree: Worktree, direction: UserCustomSplitDirection, initialInput: String, - autoCloseOnSuccess: Bool + autoCloseOnSuccess: Bool, + customCommandName: String? = nil ) { let state = state(for: worktree) guard @@ -298,6 +311,9 @@ final class WorktreeTerminalManager { if autoCloseOnSuccess { state.markSurfaceForAutoClose(newSurfaceId) } + if let customCommandName { + state.markSurfaceForCustomCommand(newSurfaceId, name: customCommandName) + } } @discardableResult diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 28443843..90b5ed1f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -45,6 +45,9 @@ final class WorktreeTerminalState { /// Surfaces that should auto-close on the next `command_finished` event with exit code 0. /// Populated by `markSurfaceForAutoClose` and consumed (one-shot) in `handleCommandFinished`. private var autoCloseSurfaceIds: Set = [] + /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success + /// toast when the command exits with code 0. One-shot: removed on the first finish event. + private var pendingCustomCommands: [UUID: String] = [:] var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } @@ -64,6 +67,9 @@ final class WorktreeTerminalState { var onCommandPaletteToggle: (() -> Void)? var onSetupScriptConsumed: (() -> Void)? var onFontSizeAdjusted: (() -> Void)? + /// Emitted when a tracked Custom Command finishes with exit code 0. + /// Payload carries the user-facing command name and run duration in milliseconds. + var onCustomCommandSucceeded: ((String, Int) -> Void)? init( runtime: GhosttyRuntime, @@ -536,6 +542,12 @@ final class WorktreeTerminalState { autoCloseSurfaceIds.contains(surfaceId) } + /// Records the user-facing Custom Command name associated with a freshly created surface, + /// so a success toast can be emitted when that surface's next command exits with code 0. + func markSurfaceForCustomCommand(_ surfaceId: UUID, name: String) { + pendingCustomCommands[surfaceId] = name + } + // Short delay lets the user see the final output before the pane disappears. private static let autoCloseDelay: Duration = .milliseconds(800) @@ -677,6 +689,7 @@ final class WorktreeTerminalState { focusedSurfaceIdByTab.removeAll() tabIsRunningById.removeAll() autoCloseSurfaceIds.removeAll() + pendingCustomCommands.removeAll() setRunScriptTabId(nil) tabManager.closeAll() } @@ -1304,6 +1317,12 @@ final class WorktreeTerminalState { continuation.finish() } + // Custom command success toast. One-shot: removed regardless of outcome. + if let commandName = pendingCustomCommands.removeValue(forKey: surfaceId), exitCode == 0 { + let durationMs = Int(durationNs / 1_000_000) + onCustomCommandSucceeded?(commandName, durationMs) + } + // Auto-close on success (exit 0). One-shot: the id is removed regardless of outcome. if autoCloseSurfaceIds.remove(surfaceId) != nil { if exitCode == 0, surfaces[surfaceId] != nil { @@ -1355,6 +1374,7 @@ final class WorktreeTerminalState { surface.closeSurface() surfaces.removeValue(forKey: surface.id) autoCloseSurfaceIds.remove(surface.id) + pendingCustomCommands.removeValue(forKey: surface.id) } focusedSurfaceIdByTab.removeValue(forKey: tabId) tabIsRunningById.removeValue(forKey: tabId) @@ -1497,12 +1517,14 @@ final class WorktreeTerminalState { view.closeSurface() surfaces.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) + pendingCustomCommands.removeValue(forKey: view.id) return } guard let node = tree.find(id: view.id) else { view.closeSurface() surfaces.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) + pendingCustomCommands.removeValue(forKey: view.id) return } let nextSurface = @@ -1513,6 +1535,7 @@ final class WorktreeTerminalState { view.closeSurface() surfaces.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) + pendingCustomCommands.removeValue(forKey: view.id) if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 72ba0f5c..5e296d4b 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -37,7 +37,13 @@ struct AppFeatureCustomCommandTests { #expect( sent.value == [ - .createTabWithInput(worktree, input: "swift test", runSetupScriptIfNew: false, autoCloseOnSuccess: false) + .createTabWithInput( + worktree, + input: "swift test", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Test" + ), ], ) } @@ -108,7 +114,13 @@ struct AppFeatureCustomCommandTests { #expect( sent.value == [ - .createSplitWithInput(worktree, direction: .down, input: "tail -f logs", autoCloseOnSuccess: false) + .createSplitWithInput( + worktree, + direction: .down, + input: "tail -f logs", + autoCloseOnSuccess: false, + customCommandName: "Tail" + ), ], ) } @@ -154,8 +166,20 @@ struct AppFeatureCustomCommandTests { #expect( sent.value == [ - .createTabWithInput(worktree, input: "make build", runSetupScriptIfNew: false, autoCloseOnSuccess: true), - .createSplitWithInput(worktree, direction: .right, input: "make lint", autoCloseOnSuccess: true), + .createTabWithInput( + worktree, + input: "make build", + runSetupScriptIfNew: false, + autoCloseOnSuccess: true, + customCommandName: "Build" + ), + .createSplitWithInput( + worktree, + direction: .right, + input: "make lint", + autoCloseOnSuccess: true, + customCommandName: "Lint" + ), ], ) } @@ -257,7 +281,13 @@ struct AppFeatureCustomCommandTests { #expect( sent.value == [ - .createTabWithInput(worktree, input: "echo five", runSetupScriptIfNew: false, autoCloseOnSuccess: false) + .createTabWithInput( + worktree, + input: "echo five", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Five" + ), ], ) } diff --git a/supacodeTests/CommandFinishedNotificationTests.swift b/supacodeTests/CommandFinishedNotificationTests.swift index 06aacb52..66814015 100644 --- a/supacodeTests/CommandFinishedNotificationTests.swift +++ b/supacodeTests/CommandFinishedNotificationTests.swift @@ -199,6 +199,61 @@ struct CommandFinishedNotificationTests { #expect(state.isMarkedForAutoClose(otherSurfaceId)) } + // MARK: - Custom command success toast + + @Test func customCommandSuccessEmitsNameAndDuration() { + let state = makeState() + var received: [(String, Int)] = [] + state.onCustomCommandSucceeded = { name, durationMs in + received.append((name, durationMs)) + } + state.markSurfaceForCustomCommand(surfaceId, name: "Build") + + state.handleCommandFinished(exitCode: 0, durationNs: 1_500_000_000, surfaceId: surfaceId) + + #expect(received.count == 1) + #expect(received.first?.0 == "Build") + #expect(received.first?.1 == 1_500) + } + + @Test func customCommandFailureSkipsSuccessEvent() { + let state = makeState() + var received: [(String, Int)] = [] + state.onCustomCommandSucceeded = { name, durationMs in + received.append((name, durationMs)) + } + state.markSurfaceForCustomCommand(surfaceId, name: "Build") + + state.handleCommandFinished(exitCode: 2, durationNs: 5_000_000_000, surfaceId: surfaceId) + + #expect(received.isEmpty) + } + + @Test func customCommandMarkIsOneShot() { + let state = makeState() + var received: [(String, Int)] = [] + state.onCustomCommandSucceeded = { name, durationMs in + received.append((name, durationMs)) + } + state.markSurfaceForCustomCommand(surfaceId, name: "Build") + + state.handleCommandFinished(exitCode: 0, durationNs: 1_000_000_000, surfaceId: surfaceId) + state.handleCommandFinished(exitCode: 0, durationNs: 2_000_000_000, surfaceId: surfaceId) + + #expect(received.count == 1) + } + + @Test func customCommandDurationFormatter() { + #expect(formatCustomCommandDuration(0) == "0ms") + #expect(formatCustomCommandDuration(250) == "250ms") + #expect(formatCustomCommandDuration(999) == "999ms") + #expect(formatCustomCommandDuration(1_000) == "1.0s") + #expect(formatCustomCommandDuration(1_540) == "1.5s") + #expect(formatCustomCommandDuration(9_900) == "9.9s") + #expect(formatCustomCommandDuration(12_000) == "12s") + #expect(formatCustomCommandDuration(75_000) == "1m 15s") + } + // MARK: - Helpers private func makeState(threshold: Int = 10) -> WorktreeTerminalState {