native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #205 from onevcat/feature/custom-command-split-autoclose

Custom Command: New Split target + Close on success

authored by

Wei Wang and committed by
GitHub
891dac9f f350ac30

+602 -54
+15 -1
supacode/Clients/Terminal/TerminalClient.swift
··· 8 8 9 9 enum Command: Equatable { 10 10 case createTab(Worktree, runSetupScriptIfNew: Bool) 11 - case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool) 11 + case createTabWithInput( 12 + Worktree, 13 + input: String, 14 + runSetupScriptIfNew: Bool, 15 + autoCloseOnSuccess: Bool, 16 + customCommandName: String? = nil 17 + ) 18 + case createSplitWithInput( 19 + Worktree, 20 + direction: UserCustomSplitDirection, 21 + input: String, 22 + autoCloseOnSuccess: Bool, 23 + customCommandName: String? = nil 24 + ) 12 25 case createTabInDirectory(Worktree, directory: URL) 13 26 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 14 27 case runScript(Worktree, script: String) ··· 32 45 } 33 46 34 47 enum Event: Equatable { 48 + case customCommandSucceeded(worktreeID: Worktree.ID, name: String, durationMs: Int) 35 49 case notificationReceived(worktreeID: Worktree.ID, title: String, body: String) 36 50 case notificationIndicatorChanged(count: Int) 37 51 case tabCreated(worktreeID: Worktree.ID)
+38 -2
supacode/Features/App/Reducer/AppFeature.swift
··· 534 534 .createTabWithInput( 535 535 worktree, 536 536 input: "$EDITOR", 537 - runSetupScriptIfNew: shouldRunSetupScript 537 + runSetupScriptIfNew: shouldRunSetupScript, 538 + autoCloseOnSuccess: false 538 539 ) 539 540 ) 540 541 } ··· 625 626 return .none 626 627 } 627 628 let command = customCommand.command 629 + let closeOnSuccess = customCommand.closeOnSuccess 630 + let commandName = customCommand.resolvedTitle 628 631 switch customCommand.execution { 629 632 case .shellScript: 630 633 return .run { _ in ··· 632 635 .createTabWithInput( 633 636 worktree, 634 637 input: command, 635 - runSetupScriptIfNew: false 638 + runSetupScriptIfNew: false, 639 + autoCloseOnSuccess: closeOnSuccess, 640 + customCommandName: commandName 641 + ) 642 + ) 643 + } 644 + case .split: 645 + let direction = customCommand.splitDirection 646 + return .run { _ in 647 + await terminalClient.send( 648 + .createSplitWithInput( 649 + worktree, 650 + direction: direction, 651 + input: command, 652 + autoCloseOnSuccess: closeOnSuccess, 653 + customCommandName: commandName 636 654 ) 637 655 ) 638 656 } ··· 899 917 case .commandPalette: 900 918 return .none 901 919 920 + case .terminalEvent(.customCommandSucceeded(_, let name, let durationMs)): 921 + let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" 922 + return .send(.repositories(.showToast(.success(message)))) 923 + 902 924 case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 903 925 var effects: [Effect<Action>] = [ 904 926 .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) ··· 994 1016 } 995 1017 } 996 1018 } 1019 + 1020 + // Renders Custom Command run duration for status toasts. 1021 + // Sub-second runs show ms; short runs show one decimal; long runs reuse the 1022 + // whole-seconds formatter used by other command-finished notifications. 1023 + func formatCustomCommandDuration(_ durationMs: Int) -> String { 1024 + if durationMs < 1_000 { 1025 + return "\(max(durationMs, 0))ms" 1026 + } 1027 + let seconds = Double(durationMs) / 1_000.0 1028 + if seconds < 10 { 1029 + return String(format: "%.1fs", seconds) 1030 + } 1031 + return WorktreeTerminalState.formatDuration(Int(seconds)) 1032 + }
+1 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 787 787 return .cancel(id: CancelID.toastAutoDismiss) 788 788 case .success, .warning: 789 789 return .run { send in 790 - try? await ContinuousClock().sleep(for: .seconds(2.5)) 790 + try? await ContinuousClock().sleep(for: .seconds(3)) 791 791 await send(.dismissToast) 792 792 } 793 793 .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true)
+56
supacode/Features/Settings/Models/UserRepositorySettings.swift
··· 34 34 var systemImage: String 35 35 var command: String 36 36 var execution: UserCustomCommandExecution 37 + var splitDirection: UserCustomSplitDirection 38 + var closeOnSuccess: Bool 37 39 var shortcut: UserCustomShortcut? 38 40 39 41 init( ··· 42 44 systemImage: String, 43 45 command: String, 44 46 execution: UserCustomCommandExecution, 47 + splitDirection: UserCustomSplitDirection = .right, 48 + closeOnSuccess: Bool = false, 45 49 shortcut: UserCustomShortcut? 46 50 ) { 47 51 self.id = id ··· 49 53 self.systemImage = systemImage 50 54 self.command = command 51 55 self.execution = execution 56 + self.splitDirection = splitDirection 57 + self.closeOnSuccess = closeOnSuccess 52 58 self.shortcut = shortcut?.normalized() 53 59 } 54 60 61 + private enum CodingKeys: String, CodingKey { 62 + case id, title, systemImage, command, execution, splitDirection, closeOnSuccess, shortcut 63 + } 64 + 65 + init(from decoder: Decoder) throws { 66 + let container = try decoder.container(keyedBy: CodingKeys.self) 67 + self.id = try container.decode(String.self, forKey: .id) 68 + self.title = try container.decode(String.self, forKey: .title) 69 + self.systemImage = try container.decode(String.self, forKey: .systemImage) 70 + self.command = try container.decode(String.self, forKey: .command) 71 + self.execution = try container.decode(UserCustomCommandExecution.self, forKey: .execution) 72 + self.splitDirection = 73 + try container.decodeIfPresent(UserCustomSplitDirection.self, forKey: .splitDirection) ?? .right 74 + self.closeOnSuccess = try container.decodeIfPresent(Bool.self, forKey: .closeOnSuccess) ?? false 75 + // Preserves raw shortcut so downstream migration/validation can inspect the original key. 76 + self.shortcut = try container.decodeIfPresent(UserCustomShortcut.self, forKey: .shortcut) 77 + } 78 + 55 79 static func `default`(index: Int) -> UserCustomCommand { 56 80 UserCustomCommand( 57 81 title: "Command \(index + 1)", ··· 69 93 systemImage: systemImage, 70 94 command: command, 71 95 execution: execution, 96 + splitDirection: splitDirection, 97 + closeOnSuccess: closeOnSuccess, 72 98 shortcut: shortcut?.normalized() 73 99 ) 74 100 } ··· 97 123 nonisolated enum UserCustomCommandExecution: String, Codable, CaseIterable, Identifiable, Sendable { 98 124 case shellScript 99 125 case terminalInput 126 + case split 100 127 101 128 var id: String { rawValue } 102 129 ··· 106 133 return "New Tab" 107 134 case .terminalInput: 108 135 return "In Place" 136 + case .split: 137 + return "New Split" 138 + } 139 + } 140 + 141 + var supportsCloseOnSuccess: Bool { 142 + switch self { 143 + case .shellScript, .split: 144 + return true 145 + case .terminalInput: 146 + return false 147 + } 148 + } 149 + } 150 + 151 + nonisolated enum UserCustomSplitDirection: String, Codable, CaseIterable, Identifiable, Sendable { 152 + case right 153 + case left 154 + case down 155 + case top 156 + 157 + var id: String { rawValue } 158 + 159 + var title: String { 160 + switch self { 161 + case .right: return "Right" 162 + case .left: return "Left" 163 + case .down: return "Down" 164 + case .top: return "Up" 109 165 } 110 166 } 111 167 }
+28
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 649 649 return "New Tab" 650 650 case .terminalInput: 651 651 return "In Place" 652 + case .split: 653 + return "New Split" 652 654 } 653 655 } 654 656 ··· 714 716 Text("Choose where this command runs and edit the script used by this repository custom command.") 715 717 .font(.caption) 716 718 .foregroundStyle(.secondary) 719 + .fixedSize(horizontal: false, vertical: true) 717 720 718 721 Picker("Execution", selection: command.execution) { 719 722 Text("New Tab") 720 723 .tag(UserCustomCommandExecution.shellScript) 721 724 Text("In Place") 722 725 .tag(UserCustomCommandExecution.terminalInput) 726 + Text("New Split") 727 + .tag(UserCustomCommandExecution.split) 723 728 } 724 729 .pickerStyle(.segmented) 725 730 731 + if command.wrappedValue.execution == .split { 732 + Picker("Split Direction", selection: command.splitDirection) { 733 + ForEach(UserCustomSplitDirection.allCases) { direction in 734 + Text(direction.title).tag(direction) 735 + } 736 + } 737 + .pickerStyle(.menu) 738 + .help("Direction to split the focused terminal pane.") 739 + } 740 + 726 741 PlainTextEditor( 727 742 text: command.command, 728 743 isMonospaced: true, ··· 734 749 Text(scriptDescription(for: command.wrappedValue.execution)) 735 750 .font(.caption) 736 751 .foregroundStyle(.secondary) 752 + .fixedSize(horizontal: false, vertical: true) 753 + 754 + if command.wrappedValue.execution.supportsCloseOnSuccess { 755 + Toggle("Close on success", isOn: command.closeOnSuccess) 756 + .help("Automatically closes the tab or split when the command exits with code 0.") 757 + .toggleStyle(.checkbox) 758 + } 737 759 } 738 760 .padding(12) 739 761 .frame(width: 420) ··· 853 875 return "npm test && swift test" 854 876 case .terminalInput: 855 877 return "pnpm test --watch" 878 + case .split: 879 + return "tail -f logs/app.log" 856 880 } 857 881 } 858 882 ··· 862 886 return "Runs in a new terminal tab." 863 887 case .terminalInput: 864 888 return "Sends input to the currently focused terminal." 889 + case .split: 890 + return "Runs in a new split of the focused terminal." 865 891 } 866 892 } 867 893 ··· 902 928 command.systemImage = updatedCommand.systemImage 903 929 command.command = updatedCommand.command 904 930 command.execution = updatedCommand.execution 931 + command.splitDirection = updatedCommand.splitDirection 932 + command.closeOnSuccess = updatedCommand.closeOnSuccess 905 933 command.shortcut = updatedCommand.shortcut 906 934 } 907 935 }
+63 -6
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 52 52 switch command { 53 53 case .createTab(let worktree, let runSetupScriptIfNew): 54 54 Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } 55 - case .createTabWithInput(let worktree, let input, let runSetupScriptIfNew): 55 + case .createTabWithInput( 56 + let worktree, let input, let runSetupScriptIfNew, let autoCloseOnSuccess, let customCommandName): 56 57 Task { 57 - createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input) 58 + createTabAsync( 59 + in: worktree, 60 + runSetupScriptIfNew: runSetupScriptIfNew, 61 + initialInput: input, 62 + autoCloseOnSuccess: autoCloseOnSuccess, 63 + customCommandName: customCommandName 64 + ) 65 + } 66 + case .createSplitWithInput(let worktree, let direction, let input, let autoCloseOnSuccess, let customCommandName): 67 + Task { 68 + createSplitAsync( 69 + in: worktree, 70 + direction: direction, 71 + initialInput: input, 72 + autoCloseOnSuccess: autoCloseOnSuccess, 73 + customCommandName: customCommandName 74 + ) 58 75 } 59 76 case .createTabInDirectory(let worktree, let directory): 60 77 Task { ··· 71 88 createTabAsync( 72 89 in: worktree, 73 90 runSetupScriptIfNew: false, 74 - initialInput: text 91 + initialInput: text, 92 + autoCloseOnSuccess: false 75 93 ) 76 94 } 77 95 } ··· 232 250 state.onFontSizeAdjusted = { [weak self] in 233 251 self?.syncPreferredFontSize(from: worktree.id) 234 252 } 253 + state.onCustomCommandSucceeded = { [weak self] name, durationMs in 254 + self?.emit(.customCommandSucceeded(worktreeID: worktree.id, name: name, durationMs: durationMs)) 255 + } 235 256 states[worktree.id] = state 236 257 terminalLogger.info("Created terminal state for worktree \(worktree.id)") 237 258 return state ··· 241 262 in worktree: Worktree, 242 263 runSetupScriptIfNew: Bool, 243 264 initialInput: String? = nil, 244 - workingDirectory: URL? = nil 265 + workingDirectory: URL? = nil, 266 + autoCloseOnSuccess: Bool = false, 267 + customCommandName: String? = nil 245 268 ) { 246 269 let state = state(for: worktree) { runSetupScriptIfNew } 247 270 let setupScript: String? 248 - if state.needsSetupScript() { 271 + // Skip setup injection when auto-close is requested so the setup script's 272 + // own exit code cannot trigger the close before the user's command runs. 273 + if !autoCloseOnSuccess, state.needsSetupScript() { 249 274 @SharedReader(.repositorySettings(worktree.repositoryRootURL)) 250 275 var settings = RepositorySettings.default 251 276 setupScript = settings.setupScript 252 277 } else { 253 278 setupScript = nil 254 279 } 255 - _ = state.createTab( 280 + let tabId = state.createTab( 256 281 setupScript: setupScript, 257 282 initialInput: initialInput, 258 283 workingDirectoryOverride: workingDirectory 259 284 ) 285 + if let tabId, let surfaceId = state.focusedSurfaceId(in: tabId) { 286 + if autoCloseOnSuccess { 287 + state.markSurfaceForAutoClose(surfaceId) 288 + } 289 + if let customCommandName { 290 + state.markSurfaceForCustomCommand(surfaceId, name: customCommandName) 291 + } 292 + } 293 + } 294 + 295 + private func createSplitAsync( 296 + in worktree: Worktree, 297 + direction: UserCustomSplitDirection, 298 + initialInput: String, 299 + autoCloseOnSuccess: Bool, 300 + customCommandName: String? = nil 301 + ) { 302 + let state = state(for: worktree) 303 + guard 304 + let newSurfaceId = state.createSplitOnFocusedSurface( 305 + direction: direction, 306 + initialInput: initialInput 307 + ) 308 + else { 309 + return 310 + } 311 + if autoCloseOnSuccess { 312 + state.markSurfaceForAutoClose(newSurfaceId) 313 + } 314 + if let customCommandName { 315 + state.markSurfaceForCustomCommand(newSurfaceId, name: customCommandName) 316 + } 260 317 } 261 318 262 319 @discardableResult
+131 -26
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 42 42 private var commandFinishedNotificationThreshold = 10 43 43 private var lastKeyInputTimeBySurface: [UUID: ContinuousClock.Instant] = [:] 44 44 private var commandFinishedWaiters: [UUID: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation] = [:] 45 + /// Surfaces that should auto-close on the next `command_finished` event with exit code 0. 46 + /// Populated by `markSurfaceForAutoClose` and consumed (one-shot) in `handleCommandFinished`. 47 + private var autoCloseSurfaceIds: Set<UUID> = [] 48 + /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success 49 + /// toast when the command exits with code 0. One-shot: removed on the first finish event. 50 + private var pendingCustomCommands: [UUID: String] = [:] 45 51 var hasUnseenNotification: Bool { 46 52 notifications.contains { !$0.isRead } 47 53 } ··· 61 67 var onCommandPaletteToggle: (() -> Void)? 62 68 var onSetupScriptConsumed: (() -> Void)? 63 69 var onFontSizeAdjusted: (() -> Void)? 70 + /// Emitted when a tracked Custom Command finishes with exit code 0. 71 + /// Payload carries the user-facing command name and run duration in milliseconds. 72 + var onCustomCommandSucceeded: ((String, Int) -> Void)? 64 73 65 74 init( 66 75 runtime: GhosttyRuntime, ··· 479 488 return tree 480 489 } 481 490 491 + /// Splits the currently focused surface and seeds the new pane with `initialInput`. 492 + /// Returns the new surface id, or nil if the split could not be created. 493 + @discardableResult 494 + func createSplitOnFocusedSurface( 495 + direction: UserCustomSplitDirection, 496 + initialInput: String 497 + ) -> UUID? { 498 + guard let tabId = tabManager.selectedTabId, 499 + let parentSurfaceId = focusedSurfaceIdByTab[tabId], 500 + let tree = trees[tabId], 501 + let parentSurface = surfaces[parentSurfaceId] 502 + else { 503 + return nil 504 + } 505 + let newSurface = createSurface( 506 + tabId: tabId, 507 + initialInput: runScriptInput(initialInput), 508 + inheritingFromSurfaceId: parentSurfaceId, 509 + context: GHOSTTY_SURFACE_CONTEXT_SPLIT 510 + ) 511 + do { 512 + let newTree = try tree.inserting( 513 + view: newSurface, 514 + at: parentSurface, 515 + direction: mapUserSplitDirection(direction) 516 + ) 517 + updateTree(newTree, for: tabId) 518 + if isCanvasManaged { 519 + newSurface.setOcclusion(true) 520 + } 521 + focusSurface(newSurface, in: tabId) 522 + return newSurface.id 523 + } catch { 524 + newSurface.closeSurface() 525 + surfaces.removeValue(forKey: newSurface.id) 526 + return nil 527 + } 528 + } 529 + 530 + /// Returns the focused surface id for a given tab, if any. 531 + func focusedSurfaceId(in tabId: TerminalTabID) -> UUID? { 532 + focusedSurfaceIdByTab[tabId] 533 + } 534 + 535 + /// Marks a surface so that its next successful `command_finished` event (exit 0) 536 + /// will trigger a one-shot close of that surface. 537 + func markSurfaceForAutoClose(_ surfaceId: UUID) { 538 + autoCloseSurfaceIds.insert(surfaceId) 539 + } 540 + 541 + func isMarkedForAutoClose(_ surfaceId: UUID) -> Bool { 542 + autoCloseSurfaceIds.contains(surfaceId) 543 + } 544 + 545 + /// Records the user-facing Custom Command name associated with a freshly created surface, 546 + /// so a success toast can be emitted when that surface's next command exits with code 0. 547 + func markSurfaceForCustomCommand(_ surfaceId: UUID, name: String) { 548 + pendingCustomCommands[surfaceId] = name 549 + } 550 + 551 + // Short delay lets the user see the final output before the pane disappears. 552 + private static let autoCloseDelay: Duration = .milliseconds(800) 553 + 554 + private func scheduleAutoClose(surfaceId: UUID) { 555 + Task { [weak self] in 556 + try? await Task.sleep(for: Self.autoCloseDelay) 557 + guard let self else { return } 558 + guard let view = self.surfaces[surfaceId] else { return } 559 + self.handleCloseRequest(for: view, processAlive: false) 560 + } 561 + } 562 + 482 563 func performSplitAction(_ action: GhosttySplitAction, for surfaceId: UUID) -> Bool { 483 564 guard let tabId = tabId(containing: surfaceId), var tree = trees[tabId] else { 484 565 return false ··· 607 688 trees.removeAll() 608 689 focusedSurfaceIdByTab.removeAll() 609 690 tabIsRunningById.removeAll() 691 + autoCloseSurfaceIds.removeAll() 692 + pendingCustomCommands.removeAll() 610 693 setRunScriptTabId(nil) 611 694 tabManager.closeAll() 612 695 } ··· 820 903 return formatCommandInput(script) 821 904 } 822 905 906 + // Env vars are injected into the surface's shell process via 907 + // `GhosttySurfaceView(environment:)`, so scripts no longer need a shell 908 + // export prefix. 823 909 private func formatCommandInput(_ script: String) -> String? { 824 - makeCommandInput( 825 - script: script, 826 - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 827 - ) 910 + makeCommandInput(script: script) 828 911 } 829 912 830 913 private func runScriptInput(_ script: String) -> String? { ··· 836 919 // Without this, the interactive shell stays alive after the script finishes 837 920 // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. 838 921 private func blockingScriptInput(_ script: String) -> String? { 839 - makeBlockingScriptInput( 840 - script: script, 841 - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 842 - ) 922 + makeBlockingScriptInput(script: script) 843 923 } 844 924 845 925 private func setRunScriptTabId(_ tabId: TerminalTabID?) { ··· 869 949 workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, 870 950 initialInput: initialInput, 871 951 fontSize: resolvedFontSize, 872 - context: context 952 + context: context, 953 + environment: worktree.scriptEnvironment 873 954 ) 874 955 // Sending a no-op font size action marks the Ghostty surface as 875 956 // "font_size_adjusted", which prevents config reloads (triggered by ··· 1236 1317 continuation.finish() 1237 1318 } 1238 1319 1320 + // Custom command success toast. One-shot: removed regardless of outcome. 1321 + if let commandName = pendingCustomCommands.removeValue(forKey: surfaceId), exitCode == 0 { 1322 + let durationMs = Int(durationNs / 1_000_000) 1323 + onCustomCommandSucceeded?(commandName, durationMs) 1324 + } 1325 + 1326 + // Auto-close on success (exit 0). One-shot: the id is removed regardless of outcome. 1327 + if autoCloseSurfaceIds.remove(surfaceId) != nil { 1328 + if exitCode == 0, surfaces[surfaceId] != nil { 1329 + scheduleAutoClose(surfaceId: surfaceId) 1330 + return 1331 + } 1332 + } 1333 + 1239 1334 guard commandFinishedNotificationEnabled else { return } 1240 1335 let durationSeconds = Int(durationNs / 1_000_000_000) 1241 1336 guard durationSeconds >= commandFinishedNotificationThreshold else { return } ··· 1278 1373 for surface in tree.leaves() { 1279 1374 surface.closeSurface() 1280 1375 surfaces.removeValue(forKey: surface.id) 1376 + autoCloseSurfaceIds.remove(surface.id) 1377 + pendingCustomCommands.removeValue(forKey: surface.id) 1281 1378 } 1282 1379 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1283 1380 tabIsRunningById.removeValue(forKey: tabId) ··· 1365 1462 } 1366 1463 } 1367 1464 1465 + private func mapUserSplitDirection(_ direction: UserCustomSplitDirection) 1466 + -> SplitTree<GhosttySurfaceView>.NewDirection 1467 + { 1468 + switch direction { 1469 + case .left: 1470 + return .left 1471 + case .right: 1472 + return .right 1473 + case .top: 1474 + return .top 1475 + case .down: 1476 + return .down 1477 + } 1478 + } 1479 + 1368 1480 private func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) 1369 1481 -> SplitTree<GhosttySurfaceView>.FocusDirection 1370 1482 { ··· 1404 1516 guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { 1405 1517 view.closeSurface() 1406 1518 surfaces.removeValue(forKey: view.id) 1519 + autoCloseSurfaceIds.remove(view.id) 1520 + pendingCustomCommands.removeValue(forKey: view.id) 1407 1521 return 1408 1522 } 1409 1523 guard let node = tree.find(id: view.id) else { 1410 1524 view.closeSurface() 1411 1525 surfaces.removeValue(forKey: view.id) 1526 + autoCloseSurfaceIds.remove(view.id) 1527 + pendingCustomCommands.removeValue(forKey: view.id) 1412 1528 return 1413 1529 } 1414 1530 let nextSurface = ··· 1418 1534 let newTree = tree.removing(node) 1419 1535 view.closeSurface() 1420 1536 surfaces.removeValue(forKey: view.id) 1537 + autoCloseSurfaceIds.remove(view.id) 1538 + pendingCustomCommands.removeValue(forKey: view.id) 1421 1539 if newTree.isEmpty { 1422 1540 trees.removeValue(forKey: tabId) 1423 1541 focusedSurfaceIdByTab.removeValue(forKey: tabId) ··· 1611 1729 } 1612 1730 } 1613 1731 1614 - nonisolated func makeCommandInput( 1615 - script: String, 1616 - environmentExportPrefix: String 1617 - ) -> String? { 1732 + nonisolated func makeCommandInput(script: String) -> String? { 1618 1733 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1619 1734 guard !trimmed.isEmpty else { return nil } 1620 - return environmentExportPrefix + trimmed + "\n" 1735 + return trimmed + "\n" 1621 1736 } 1622 1737 1623 - nonisolated func makeBlockingScriptInput( 1624 - script: String, 1625 - environmentExportPrefix: String 1626 - ) -> String? { 1627 - guard 1628 - let input = makeCommandInput( 1629 - script: script, 1630 - environmentExportPrefix: environmentExportPrefix 1631 - ) 1632 - else { 1633 - return nil 1634 - } 1738 + nonisolated func makeBlockingScriptInput(script: String) -> String? { 1739 + guard let input = makeCommandInput(script: script) else { return nil } 1635 1740 return input + "exit\n" 1636 1741 }
+40
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 108 108 private var surfaceRef: GhosttyRuntime.SurfaceReference? 109 109 private let workingDirectoryCString: UnsafeMutablePointer<CChar>? 110 110 private let initialInputCString: UnsafeMutablePointer<CChar>? 111 + private let envVarCStrings: [UnsafeMutablePointer<CChar>] 112 + private let envVarEntries: UnsafeMutablePointer<ghostty_env_var_s>? 113 + private let envVarCount: Int 111 114 private let fontSize: Float32 112 115 private let context: ghostty_surface_context_e 113 116 private let skipsSurfaceCreationForTesting: Bool ··· 222 225 initialInput: String? = nil, 223 226 fontSize: Float32? = nil, 224 227 context: ghostty_surface_context_e, 228 + environment: [String: String] = [:], 225 229 skipsSurfaceCreationForTesting: Bool = false 226 230 ) { 227 231 self.runtime = runtime ··· 242 246 } else { 243 247 initialInputCString = nil 244 248 } 249 + let sortedEnv = environment.sorted { $0.key < $1.key } 250 + var allocatedStrings: [UnsafeMutablePointer<CChar>] = [] 251 + allocatedStrings.reserveCapacity(sortedEnv.count * 2) 252 + for (key, value) in sortedEnv { 253 + guard let keyPtr = key.withCString({ strdup($0) }), 254 + let valuePtr = value.withCString({ strdup($0) }) 255 + else { continue } 256 + allocatedStrings.append(keyPtr) 257 + allocatedStrings.append(valuePtr) 258 + } 259 + envVarCStrings = allocatedStrings 260 + let pairCount = allocatedStrings.count / 2 261 + if pairCount > 0 { 262 + let entries = UnsafeMutablePointer<ghostty_env_var_s>.allocate(capacity: pairCount) 263 + for index in 0..<pairCount { 264 + entries[index] = ghostty_env_var_s( 265 + key: UnsafePointer(allocatedStrings[index * 2]), 266 + value: UnsafePointer(allocatedStrings[index * 2 + 1]) 267 + ) 268 + } 269 + envVarEntries = entries 270 + envVarCount = pairCount 271 + } else { 272 + envVarEntries = nil 273 + envVarCount = 0 274 + } 245 275 super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) 246 276 wantsLayer = true 247 277 bridge.surfaceView = self ··· 280 310 } 281 311 if let initialInputCString { 282 312 free(initialInputCString) 313 + } 314 + if let envVarEntries { 315 + envVarEntries.deallocate() 316 + } 317 + for pointer in envVarCStrings { 318 + free(pointer) 283 319 } 284 320 } 285 321 ··· 1047 1083 config.working_directory = workingDirectoryCString.map { UnsafePointer($0) } 1048 1084 config.initial_input = initialInputCString.map { UnsafePointer($0) } 1049 1085 config.context = context 1086 + if let envVarEntries, envVarCount > 0 { 1087 + config.env_vars = envVarEntries 1088 + config.env_var_count = envVarCount 1089 + } 1050 1090 surface = ghostty_surface_new(app, &config) 1051 1091 bridge.surface = surface 1052 1092 occlusionState.reset()
+132 -2
supacodeTests/AppFeatureCustomCommandTests.swift
··· 37 37 38 38 #expect( 39 39 sent.value == [ 40 - .createTabWithInput(worktree, input: "swift test", runSetupScriptIfNew: false) 40 + .createTabWithInput( 41 + worktree, 42 + input: "swift test", 43 + runSetupScriptIfNew: false, 44 + autoCloseOnSuccess: false, 45 + customCommandName: "Test" 46 + ), 41 47 ], 42 48 ) 43 49 } ··· 77 83 ) 78 84 } 79 85 86 + @Test(.dependencies) func splitCommandCreatesSplitWithInput() async { 87 + let worktree = makeWorktree() 88 + let sent = LockIsolated<[TerminalClient.Command]>([]) 89 + var state = AppFeature.State( 90 + repositories: makeRepositoriesState(worktree: worktree), 91 + settings: SettingsFeature.State() 92 + ) 93 + state.selectedCustomCommands = [ 94 + UserCustomCommand( 95 + title: "Tail", 96 + systemImage: "doc.text", 97 + command: "tail -f logs", 98 + execution: .split, 99 + splitDirection: .down, 100 + shortcut: nil, 101 + ), 102 + ] 103 + 104 + let store = TestStore(initialState: state) { 105 + AppFeature() 106 + } withDependencies: { 107 + $0.terminalClient.send = { command in 108 + sent.withValue { $0.append(command) } 109 + } 110 + } 111 + 112 + await store.send(.runCustomCommand(0)) 113 + await store.finish() 114 + 115 + #expect( 116 + sent.value == [ 117 + .createSplitWithInput( 118 + worktree, 119 + direction: .down, 120 + input: "tail -f logs", 121 + autoCloseOnSuccess: false, 122 + customCommandName: "Tail" 123 + ), 124 + ], 125 + ) 126 + } 127 + 128 + @Test(.dependencies) func closeOnSuccessFlagIsForwarded() async { 129 + let worktree = makeWorktree() 130 + let sent = LockIsolated<[TerminalClient.Command]>([]) 131 + var state = AppFeature.State( 132 + repositories: makeRepositoriesState(worktree: worktree), 133 + settings: SettingsFeature.State() 134 + ) 135 + state.selectedCustomCommands = [ 136 + UserCustomCommand( 137 + title: "Build", 138 + systemImage: "hammer", 139 + command: "make build", 140 + execution: .shellScript, 141 + closeOnSuccess: true, 142 + shortcut: nil, 143 + ), 144 + UserCustomCommand( 145 + title: "Lint", 146 + systemImage: "checkmark", 147 + command: "make lint", 148 + execution: .split, 149 + splitDirection: .right, 150 + closeOnSuccess: true, 151 + shortcut: nil, 152 + ), 153 + ] 154 + 155 + let store = TestStore(initialState: state) { 156 + AppFeature() 157 + } withDependencies: { 158 + $0.terminalClient.send = { command in 159 + sent.withValue { $0.append(command) } 160 + } 161 + } 162 + 163 + await store.send(.runCustomCommand(0)) 164 + await store.send(.runCustomCommand(1)) 165 + await store.finish() 166 + 167 + #expect( 168 + sent.value == [ 169 + .createTabWithInput( 170 + worktree, 171 + input: "make build", 172 + runSetupScriptIfNew: false, 173 + autoCloseOnSuccess: true, 174 + customCommandName: "Build" 175 + ), 176 + .createSplitWithInput( 177 + worktree, 178 + direction: .right, 179 + input: "make lint", 180 + autoCloseOnSuccess: true, 181 + customCommandName: "Lint" 182 + ), 183 + ], 184 + ) 185 + } 186 + 187 + @Test func userCustomCommandDecodesWithoutNewFields() throws { 188 + let legacyJSON = """ 189 + { 190 + "id": "abc", 191 + "title": "Legacy", 192 + "systemImage": "terminal", 193 + "command": "echo hi", 194 + "execution": "shellScript" 195 + } 196 + """ 197 + let data = Data(legacyJSON.utf8) 198 + let decoded = try JSONDecoder().decode(UserCustomCommand.self, from: data) 199 + #expect(decoded.splitDirection == .right) 200 + #expect(decoded.closeOnSuccess == false) 201 + #expect(decoded.execution == .shellScript) 202 + } 203 + 80 204 @Test(.dependencies) func invalidCommandIndexDoesNothing() async { 81 205 let worktree = makeWorktree() 82 206 let sent = LockIsolated<[TerminalClient.Command]>([]) ··· 157 281 158 282 #expect( 159 283 sent.value == [ 160 - .createTabWithInput(worktree, input: "echo five", runSetupScriptIfNew: false) 284 + .createTabWithInput( 285 + worktree, 286 + input: "echo five", 287 + runSetupScriptIfNew: false, 288 + autoCloseOnSuccess: false, 289 + customCommandName: "Five" 290 + ), 161 291 ], 162 292 ) 163 293 }
+89
supacodeTests/CommandFinishedNotificationTests.swift
··· 165 165 #expect(state.notifications.count == 1) 166 166 } 167 167 168 + // MARK: - Auto-close on success 169 + 170 + @Test func autoCloseFlagIsConsumedOnSuccess() { 171 + let state = makeState() 172 + state.markSurfaceForAutoClose(surfaceId) 173 + #expect(state.isMarkedForAutoClose(surfaceId)) 174 + 175 + state.handleCommandFinished(exitCode: 0, durationNs: 1_000_000_000, surfaceId: surfaceId) 176 + 177 + #expect(!state.isMarkedForAutoClose(surfaceId)) 178 + } 179 + 180 + @Test func autoCloseFlagIsConsumedOnFailureButDoesNotClose() { 181 + let state = makeState() 182 + state.markSurfaceForAutoClose(surfaceId) 183 + 184 + state.handleCommandFinished(exitCode: 1, durationNs: 30_000_000_000, surfaceId: surfaceId) 185 + 186 + #expect(!state.isMarkedForAutoClose(surfaceId)) 187 + // Standard failure notification still fires since close was not triggered. 188 + #expect(state.notifications.count == 1) 189 + #expect(state.notifications.first?.title == "Command failed") 190 + } 191 + 192 + @Test func unmarkedSurfaceDoesNotConsumeAutoCloseState() { 193 + let state = makeState() 194 + let otherSurfaceId = UUID() 195 + state.markSurfaceForAutoClose(otherSurfaceId) 196 + 197 + state.handleCommandFinished(exitCode: 0, durationNs: 15_000_000_000, surfaceId: surfaceId) 198 + 199 + #expect(state.isMarkedForAutoClose(otherSurfaceId)) 200 + } 201 + 202 + // MARK: - Custom command success toast 203 + 204 + @Test func customCommandSuccessEmitsNameAndDuration() { 205 + let state = makeState() 206 + var received: [(String, Int)] = [] 207 + state.onCustomCommandSucceeded = { name, durationMs in 208 + received.append((name, durationMs)) 209 + } 210 + state.markSurfaceForCustomCommand(surfaceId, name: "Build") 211 + 212 + state.handleCommandFinished(exitCode: 0, durationNs: 1_500_000_000, surfaceId: surfaceId) 213 + 214 + #expect(received.count == 1) 215 + #expect(received.first?.0 == "Build") 216 + #expect(received.first?.1 == 1_500) 217 + } 218 + 219 + @Test func customCommandFailureSkipsSuccessEvent() { 220 + let state = makeState() 221 + var received: [(String, Int)] = [] 222 + state.onCustomCommandSucceeded = { name, durationMs in 223 + received.append((name, durationMs)) 224 + } 225 + state.markSurfaceForCustomCommand(surfaceId, name: "Build") 226 + 227 + state.handleCommandFinished(exitCode: 2, durationNs: 5_000_000_000, surfaceId: surfaceId) 228 + 229 + #expect(received.isEmpty) 230 + } 231 + 232 + @Test func customCommandMarkIsOneShot() { 233 + let state = makeState() 234 + var received: [(String, Int)] = [] 235 + state.onCustomCommandSucceeded = { name, durationMs in 236 + received.append((name, durationMs)) 237 + } 238 + state.markSurfaceForCustomCommand(surfaceId, name: "Build") 239 + 240 + state.handleCommandFinished(exitCode: 0, durationNs: 1_000_000_000, surfaceId: surfaceId) 241 + state.handleCommandFinished(exitCode: 0, durationNs: 2_000_000_000, surfaceId: surfaceId) 242 + 243 + #expect(received.count == 1) 244 + } 245 + 246 + @Test func customCommandDurationFormatter() { 247 + #expect(formatCustomCommandDuration(0) == "0ms") 248 + #expect(formatCustomCommandDuration(250) == "250ms") 249 + #expect(formatCustomCommandDuration(999) == "999ms") 250 + #expect(formatCustomCommandDuration(1_000) == "1.0s") 251 + #expect(formatCustomCommandDuration(1_540) == "1.5s") 252 + #expect(formatCustomCommandDuration(9_900) == "9.9s") 253 + #expect(formatCustomCommandDuration(12_000) == "12s") 254 + #expect(formatCustomCommandDuration(75_000) == "1m 15s") 255 + } 256 + 168 257 // MARK: - Helpers 169 258 170 259 private func makeState(threshold: Int = 10) -> WorktreeTerminalState {
+9 -16
supacodeTests/WorktreeEnvironmentTests.swift
··· 63 63 } 64 64 65 65 @Test func blockingScriptInputUsesPortableBareExit() { 66 - let worktree = Worktree( 67 - id: "/tmp/repo/wt-1", 68 - name: "feature-branch", 69 - detail: "detail", 70 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 71 - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 72 - ) 73 - 74 66 let input = makeBlockingScriptInput( 75 67 script: """ 76 68 docker compose down 77 69 codex exec "test" 78 - """, 79 - environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 70 + """ 80 71 ) 81 72 82 73 #expect(input?.contains("docker compose down\ncodex exec \"test\"\nexit\n") == true) ··· 84 75 #expect(input?.contains("exit $?") == false) 85 76 } 86 77 78 + @Test func commandInputDoesNotPrependEnvExports() { 79 + // Environment variables are injected via ghostty_surface_config.env_vars 80 + // now, so the shell input itself must stay free of `export` prefixes. 81 + let input = makeCommandInput(script: "make build") 82 + #expect(input == "make build\n") 83 + } 84 + 87 85 @Test func blockingScriptInputReturnsNilForWhitespaceOnlyScripts() { 88 - #expect( 89 - makeBlockingScriptInput( 90 - script: " \n ", 91 - environmentExportPrefix: "export PROWL_ROOT_PATH='/tmp/repo'\n" 92 - ) == nil 93 - ) 86 + #expect(makeBlockingScriptInput(script: " \n ") == nil) 94 87 } 95 88 }