native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #187 from supabitapp/sbertix/run-script-stale

authored by

khoi and committed by
GitHub
89ef4462 b203d97d

+252 -112
-2
supacode/Clients/Terminal/TerminalClient.swift
··· 9 9 case createTab(Worktree, runSetupScriptIfNew: Bool) 10 10 case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool) 11 11 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 12 - case runScript(Worktree, script: String) 13 12 case stopRunScript(Worktree) 14 13 case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) 15 14 case closeFocusedTab(Worktree) ··· 32 31 case tabClosed(worktreeID: Worktree.ID) 33 32 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 34 33 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 35 - case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) 36 34 case blockingScriptCompleted(worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?) 37 35 case commandPaletteToggleRequested(worktreeID: Worktree.ID) 38 36 case setupScriptConsumed(worktreeID: Worktree.ID)
+5 -11
supacode/Features/App/Reducer/AppFeature.swift
··· 20 20 var selectedRunScript: String = "" 21 21 var runScriptDraft: String = "" 22 22 var isRunScriptPromptPresented = false 23 - var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] 24 23 var notificationIndicatorCount: Int = 0 25 24 var lastKnownSystemNotificationsEnabled: Bool 26 25 @Presents var alert: AlertState<Alert>? ··· 190 189 repositories.flatMap { $0.worktrees.map(\.id) } 191 190 .filter { !archivedIDs.contains($0) || deleteScriptIDs.contains($0) } 192 191 ) 192 + state.repositories.runScriptWorktreeIDs.formIntersection(ids) 193 193 let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) 194 194 let worktrees = state.repositories.worktreesForInfoWatcher() 195 - state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } 196 195 if case .repository(let repositoryID)? = state.settings.selection, 197 196 !repositories.contains(where: { $0.id == repositoryID }) 198 197 { ··· 417 416 return .none 418 417 } 419 418 analyticsClient.capture("script_run", nil) 419 + state.repositories.runScriptWorktreeIDs.insert(worktree.id) 420 420 let script = state.selectedRunScript 421 421 return .run { _ in 422 - await terminalClient.send(.runScript(worktree, script: script)) 422 + await terminalClient.send(.runBlockingScript(worktree, kind: .run, script: script)) 423 423 } 424 424 425 425 case .runScriptDraftChanged(let script): ··· 671 671 } 672 672 } 673 673 674 - case .terminalEvent(.runScriptStatusChanged(let worktreeID, let isRunning)): 675 - if isRunning { 676 - state.runScriptStatusByWorktreeID[worktreeID] = true 677 - } else { 678 - state.runScriptStatusByWorktreeID.removeValue(forKey: worktreeID) 679 - } 680 - return .none 681 - 682 674 case .terminalEvent(.commandPaletteToggleRequested(let worktreeID)): 683 675 if state.commandPalette.isPresented { 684 676 return .send(.commandPalette(.setPresented(false))) ··· 692 684 693 685 case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode)): 694 686 switch kind { 687 + case .run: 688 + return .send(.repositories(.runScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 695 689 case .archive: 696 690 return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 697 691 case .delete:
+14 -2
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 71 71 var pendingWorktrees: [PendingWorktree] = [] 72 72 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 73 73 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 74 + var runScriptWorktreeIDs: Set<Worktree.ID> = [] 74 75 var archivingWorktreeIDs: Set<Worktree.ID> = [] 75 76 var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 76 77 var deletingWorktreeIDs: Set<Worktree.ID> = [] ··· 185 186 ) 186 187 case consumeSetupScript(Worktree.ID) 187 188 case consumeTerminalFocus(Worktree.ID) 189 + case runScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 188 190 case requestArchiveWorktree(Worktree.ID, Repository.ID) 189 191 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 190 192 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) ··· 1283 1285 } 1284 1286 ) 1285 1287 1288 + case .runScriptCompleted(let worktreeID, _): 1289 + guard state.runScriptWorktreeIDs.contains(worktreeID) else { 1290 + repositoriesLogger.debug("Ignoring runScriptCompleted for \(worktreeID): not in runScriptWorktreeIDs") 1291 + return .none 1292 + } 1293 + state.runScriptWorktreeIDs.remove(worktreeID) 1294 + return .none 1295 + 1286 1296 case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): 1287 1297 guard let repository = state.repositories[id: repositoryID], 1288 1298 let worktree = repository.worktrees[id: worktreeID] ··· 1330 1340 case let code?: 1331 1341 state.alert = messageAlert( 1332 1342 title: "Archive script failed", 1333 - message: "\(blockingScriptExitMessage(code))\nCheck the ARCHIVE SCRIPT tab for details." 1343 + message: "\(blockingScriptExitMessage(code))\nCheck the Archive Script tab for details." 1334 1344 ) 1335 1345 return .none 1336 1346 } ··· 1582 1592 case let code?: 1583 1593 state.alert = messageAlert( 1584 1594 title: "Delete script failed", 1585 - message: "\(blockingScriptExitMessage(code))\nCheck the DELETE SCRIPT tab for details." 1595 + message: "\(blockingScriptExitMessage(code))\nCheck the Delete Script tab for details." 1586 1596 ) 1587 1597 return .none 1588 1598 } ··· 2734 2744 let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { 2735 2745 availableWorktreeIDs.contains($0) 2736 2746 } 2747 + let filteredRunScriptIDs = state.runScriptWorktreeIDs 2737 2748 let filteredArchivingIDs = state.archivingWorktreeIDs 2738 2749 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2739 2750 availableWorktreeIDs.contains($0.key) ··· 2747 2758 state.deleteScriptWorktreeIDs = filteredDeleteScriptIDs 2748 2759 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2749 2760 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2761 + state.runScriptWorktreeIDs = filteredRunScriptIDs 2750 2762 state.archivingWorktreeIDs = filteredArchivingIDs 2751 2763 state.worktreeInfoByID = filteredWorktreeInfo 2752 2764 }
+1 -1
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 32 32 && !showsMultiSelectionSummary 33 33 let openActionSelection = state.openActionSelection 34 34 let runScriptEnabled = hasActiveWorktree 35 - let runScriptIsRunning = selectedWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true 35 + let runScriptIsRunning = selectedWorktree.map { state.repositories.runScriptWorktreeIDs.contains($0.id) } == true 36 36 let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) 37 37 let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in 38 38 count + repository.unseenWorktreeCount
+1 -1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 160 160 hideSubtitle: hideSubtitle, 161 161 hideSubtitleOnMatch: hideSubtitleOnMatch, 162 162 showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 163 - isRunScriptRunning: terminalManager.isRunScriptRunning(for: row.id), 163 + isRunScriptRunning: store.state.runScriptWorktreeIDs.contains(row.id), 164 164 showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), 165 165 notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [], 166 166 shortcutHint: shortcutHint
+2 -7
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 42 42 case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): 43 43 let state = state(for: worktree) { runSetupScriptIfNew } 44 44 state.ensureInitialTab(focusing: focusing) 45 - case .runScript(let worktree, let script): 46 - _ = state(for: worktree).runScript(script) 47 45 case .stopRunScript(let worktree): 48 46 _ = state(for: worktree).stopRunScript() 49 47 case .runBlockingScript(let worktree, let kind, let script): ··· 161 159 state.onTaskStatusChanged = { [weak self] status in 162 160 self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) 163 161 } 164 - state.onRunScriptStatusChanged = { [weak self] isRunning in 165 - self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) 166 - } 167 162 state.onBlockingScriptCompleted = { [weak self] kind, exitCode in 168 163 self?.emit(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode)) 169 164 } ··· 230 225 states[worktreeID]?.taskStatus 231 226 } 232 227 233 - func isRunScriptRunning(for worktreeID: Worktree.ID) -> Bool { 234 - states[worktreeID]?.isRunScriptRunning == true 228 + func isBlockingScriptRunning(kind: BlockingScriptKind, for worktreeID: Worktree.ID) -> Bool { 229 + states[worktreeID]?.isBlockingScriptRunning(kind: kind) == true 235 230 } 236 231 237 232 func setNotificationsEnabled(_ enabled: Bool) {
+20 -8
supacode/Features/Terminal/Models/BlockingScriptKind.swift
··· 1 - /// Identifies the kind of script that blocks a worktree state transition 2 - /// and runs in a dedicated terminal tab. Adding a new case requires handling 3 - /// in `AppFeature`'s `.blockingScriptCompleted` event router. 4 - enum BlockingScriptKind: Hashable, Sendable { 1 + /// Identifies the kind of script that runs in a dedicated terminal tab 2 + /// with exit-code tracking. Some kinds (archive, delete) block worktree 3 + /// state transitions until the script completes. Adding a new case 4 + /// requires handling in `AppFeature`'s `.blockingScriptCompleted` event router. 5 + enum BlockingScriptKind: Hashable, Sendable, CaseIterable { 6 + case run 5 7 case archive 6 8 case delete 7 9 8 10 var tabTitle: String { 9 11 switch self { 10 - case .archive: return "ARCHIVE SCRIPT" 11 - case .delete: return "DELETE SCRIPT" 12 + case .run: "Run Script" 13 + case .archive: "Archive Script" 14 + case .delete: "Delete Script" 12 15 } 13 16 } 14 17 15 18 var tabIcon: String { 16 19 switch self { 17 - case .archive: return "archivebox.fill" 18 - case .delete: return "trash.fill" 20 + case .run: "play.fill" 21 + case .archive: "archivebox.fill" 22 + case .delete: "trash.fill" 23 + } 24 + } 25 + 26 + var tabColor: TerminalTabTintColor { 27 + switch self { 28 + case .run: .green 29 + case .archive: .orange 30 + case .delete: .red 19 31 } 20 32 } 21 33 }
+4 -1
supacode/Features/Terminal/Models/TerminalTabItem.swift
··· 6 6 var icon: String? 7 7 var isDirty: Bool 8 8 var isTitleLocked: Bool 9 + var tintColor: TerminalTabTintColor? 9 10 10 11 init( 11 12 id: TerminalTabID = TerminalTabID(), 12 13 title: String, 13 14 icon: String?, 14 15 isDirty: Bool = false, 15 - isTitleLocked: Bool = false 16 + isTitleLocked: Bool = false, 17 + tintColor: TerminalTabTintColor? = nil 16 18 ) { 17 19 self.id = id 18 20 self.title = title 19 21 self.icon = icon 20 22 self.isDirty = isDirty 21 23 self.isTitleLocked = isTitleLocked 24 + self.tintColor = tintColor 22 25 } 23 26 }
+15 -2
supacode/Features/Terminal/Models/TerminalTabManager.swift
··· 6 6 var tabs: [TerminalTabItem] = [] 7 7 var selectedTabId: TerminalTabID? 8 8 9 - func createTab(title: String, icon: String?, isTitleLocked: Bool = false) -> TerminalTabID { 10 - let tab = TerminalTabItem(title: title, icon: icon, isTitleLocked: isTitleLocked) 9 + func createTab( 10 + title: String, 11 + icon: String?, 12 + isTitleLocked: Bool = false, 13 + tintColor: TerminalTabTintColor? = nil 14 + ) -> TerminalTabID { 15 + let tab = TerminalTabItem(title: title, icon: icon, isTitleLocked: isTitleLocked, tintColor: tintColor) 11 16 if let selectedTabId, 12 17 let selectedIndex = tabs.firstIndex(where: { $0.id == selectedTabId }) 13 18 { ··· 28 33 guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 29 34 guard !tabs[index].isTitleLocked else { return } 30 35 tabs[index].title = title 36 + } 37 + 38 + func unlockAndUpdateTitle(_ id: TerminalTabID, title: String) { 39 + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 40 + tabs[index].isTitleLocked = false 41 + tabs[index].title = title 42 + tabs[index].icon = nil 43 + tabs[index].tintColor = nil 31 44 } 32 45 33 46 func updateDirty(_ id: TerminalTabID, isDirty: Bool) {
+17
supacode/Features/Terminal/Models/TerminalTabTintColor.swift
··· 1 + import SwiftUI 2 + 3 + /// Color token for terminal tab tint indicators, used in place of 4 + /// `Color` so that `TerminalTabItem` can remain `Equatable` and `Sendable`. 5 + enum TerminalTabTintColor: Hashable, Sendable { 6 + case green 7 + case orange 8 + case red 9 + 10 + var color: Color { 11 + switch self { 12 + case .green: .green 13 + case .orange: .orange 14 + case .red: .red 15 + } 16 + } 17 + }
+21 -55
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 24 24 private var surfaces: [UUID: GhosttySurfaceView] = [:] 25 25 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 26 26 var tabIsRunningById: [TerminalTabID: Bool] = [:] 27 - private var runScriptTabId: TerminalTabID? 28 27 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 29 28 private var blockingScriptCommandFinished: Set<TerminalTabID> = [] 30 29 private var blockingScriptLastCommandExitCode: [TerminalTabID: Int] = [:] ··· 45 44 var onTabClosed: (() -> Void)? 46 45 var onFocusChanged: ((UUID) -> Void)? 47 46 var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? 48 - var onRunScriptStatusChanged: ((Bool) -> Void)? 49 47 var onBlockingScriptCompleted: ((BlockingScriptKind, Int?) -> Void)? 50 48 var onCommandPaletteToggle: (() -> Void)? 51 49 var onSetupScriptConsumed: (() -> Void)? ··· 65 63 tabIsRunningById.values.contains(true) ? .running : .idle 66 64 } 67 65 68 - var isRunScriptRunning: Bool { 69 - runScriptTabId != nil 66 + func isBlockingScriptRunning(kind: BlockingScriptKind) -> Bool { 67 + blockingScripts.values.contains(kind) 70 68 } 71 69 72 70 func ensureInitialTab(focusing: Bool) { ··· 103 101 let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() 104 102 let title = "\(worktree.name) \(nextTabIndex())" 105 103 let setupInput = setupScriptInput(setupScript: setupScript) 106 - let commandInput = initialInput.flatMap { runScriptInput($0) } 104 + let commandInput = initialInput.flatMap { formatCommandInput($0) } 107 105 let resolvedInput: String? 108 106 switch (setupInput, commandInput) { 109 107 case (nil, nil): ··· 137 135 } 138 136 139 137 @discardableResult 140 - func runScript(_ script: String) -> TerminalTabID? { 141 - guard let input = runScriptInput(script) else { return nil } 142 - if let existing = runScriptTabId { 143 - closeTab(existing) 144 - } 145 - let tabId = createTab( 146 - TabCreation( 147 - title: "RUN SCRIPT", 148 - icon: "play.fill", 149 - isTitleLocked: true, 150 - initialInput: input, 151 - focusing: true, 152 - inheritingFromSurfaceId: currentFocusedSurfaceId(), 153 - context: GHOSTTY_SURFACE_CONTEXT_TAB 154 - ) 155 - ) 156 - setRunScriptTabId(tabId) 157 - return tabId 158 - } 159 - 160 - @discardableResult 161 138 func stopRunScript() -> Bool { 162 - guard let runScriptTabId else { return false } 163 - closeTab(runScriptTabId) 139 + guard let tabId = blockingScripts.first(where: { $0.value == .run })?.key else { return false } 140 + closeTab(tabId) 164 141 return true 165 142 } 166 143 ··· 184 161 title: kind.tabTitle, 185 162 icon: kind.tabIcon, 186 163 isTitleLocked: true, 164 + tintColor: kind.tabColor, 187 165 initialInput: input, 188 166 focusing: true, 189 167 inheritingFromSurfaceId: currentFocusedSurfaceId(), ··· 197 175 } 198 176 blockingScripts[tabId] = kind 199 177 lastBlockingScriptTabByKind[kind] = tabId 178 + 200 179 blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") 201 180 return tabId 202 181 } ··· 205 184 let title: String 206 185 let icon: String? 207 186 let isTitleLocked: Bool 187 + var tintColor: TerminalTabTintColor? 208 188 let initialInput: String? 209 189 let focusing: Bool 210 190 let inheritingFromSurfaceId: UUID? ··· 215 195 let tabId = tabManager.createTab( 216 196 title: creation.title, 217 197 icon: creation.icon, 218 - isTitleLocked: creation.isTitleLocked 198 + isTitleLocked: creation.isTitleLocked, 199 + tintColor: creation.tintColor 219 200 ) 220 201 let tree = splitTree( 221 202 for: tabId, ··· 345 326 } 346 327 347 328 func closeTab(_ tabId: TerminalTabID) { 348 - let wasRunScriptTab = tabId == runScriptTabId 349 329 let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) 350 330 // Clear lingering tab tracking for completed or non-blocking tabs. 351 331 for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { ··· 359 339 lastEmittedFocusSurfaceId = nil 360 340 } 361 341 emitTaskStatusIfChanged() 362 - if wasRunScriptTab { 363 - setRunScriptTabId(nil) 364 - } 342 + 365 343 if let closedBlockingKind { 366 344 blockingScriptCommandFinished.remove(tabId) 367 345 blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 346 + blockingScriptLogger.info("\(closedBlockingKind.tabTitle) cancelled (tab closed)") 368 347 onBlockingScriptCompleted?(closedBlockingKind, nil) 369 348 } 370 349 onTabClosed?() ··· 534 513 trees.removeAll() 535 514 focusedSurfaceIdByTab.removeAll() 536 515 tabIsRunningById.removeAll() 537 - setRunScriptTabId(nil) 538 516 let pendingKinds = Set(blockingScripts.values) 539 517 blockingScripts.removeAll() 540 518 blockingScriptCommandFinished.removeAll() 541 519 blockingScriptLastCommandExitCode.removeAll() 542 520 lastBlockingScriptTabByKind.removeAll() 521 + 543 522 for kind in pendingKinds { 544 523 onBlockingScriptCompleted?(kind, nil) 545 524 } ··· 600 579 601 580 private func setupScriptInput(setupScript: String?) -> String? { 602 581 guard pendingSetupScript, let script = setupScript else { return nil } 603 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 604 - if trimmed.isEmpty { 605 - return nil 606 - } 607 - return worktree.scriptEnvironmentExportPrefix + trimmed + "\n" 582 + return formatCommandInput(script) 608 583 } 609 584 610 - private func runScriptInput(_ script: String) -> String? { 585 + private func formatCommandInput(_ script: String) -> String? { 611 586 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 612 - if trimmed.isEmpty { 613 - return nil 614 - } 587 + guard !trimmed.isEmpty else { return nil } 615 588 return worktree.scriptEnvironmentExportPrefix + trimmed + "\n" 616 589 } 617 590 ··· 638 611 blockingScripts.removeValue(forKey: tabId) 639 612 blockingScriptCommandFinished.remove(tabId) 640 613 blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 614 + tabManager.unlockAndUpdateTitle(tabId, title: "\(worktree.name) \(nextTabIndex())") 615 + 641 616 Task { @MainActor [weak self] in 642 617 // Bail out if a new script of the same kind started before this ran. 643 618 guard self?.blockingScripts.values.contains(kind) != true else { ··· 652 627 // asynchronously to avoid reentrancy into Ghostty's callback during surface teardown. 653 628 private func handleBlockingScriptChildExited(tabId: TerminalTabID, exitCode: UInt32) { 654 629 guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } 630 + tabManager.unlockAndUpdateTitle(tabId, title: "\(worktree.name) \(nextTabIndex())") 631 + 655 632 guard blockingScriptCommandFinished.remove(tabId) != nil else { 656 633 blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 657 634 // No command ran to completion — user pressed Ctrl+D or ··· 678 655 if code == 0, self?.trees[tabId] != nil { 679 656 self?.closeTab(tabId) 680 657 } 681 - } 682 - } 683 - 684 - private func setRunScriptTabId(_ tabId: TerminalTabID?) { 685 - let wasRunning = runScriptTabId != nil 686 - runScriptTabId = tabId 687 - let isRunning = tabId != nil 688 - if wasRunning != isRunning { 689 - onRunScriptStatusChanged?(isRunning) 690 658 } 691 659 } 692 660 ··· 986 954 trees.removeValue(forKey: tabId) 987 955 focusedSurfaceIdByTab.removeValue(forKey: tabId) 988 956 tabManager.closeTab(tabId) 989 - if tabId == runScriptTabId { 990 - setRunScriptTabId(nil) 991 - } 992 957 if let kind = blockingScripts.removeValue(forKey: tabId) { 993 958 blockingScriptCommandFinished.remove(tabId) 994 959 blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 995 960 lastBlockingScriptTabByKind.removeValue(forKey: kind) 961 + 996 962 onBlockingScriptCompleted?(kind, nil) 997 963 } else { 998 964 for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId {
+5 -5
supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift
··· 5 5 var isPressing: Bool 6 6 var isDragging: Bool 7 7 var isHovering: Bool 8 + var tintColor: TerminalTabTintColor? 8 9 9 10 var body: some View { 10 11 ZStack(alignment: .top) { ··· 16 17 TerminalTabBarColors.inactiveTabBackground 17 18 } 18 19 19 - if isActive { 20 - Rectangle() 21 - .fill(Color.accentColor) 22 - .frame(height: TerminalTabBarMetrics.activeIndicatorHeight) 23 - } 20 + Rectangle() 21 + .fill(tintColor?.color ?? .accentColor) 22 + .frame(height: TerminalTabBarMetrics.activeIndicatorHeight) 23 + .opacity(isActive || tintColor != nil ? 1 : 0) 24 24 25 25 if !isActive { 26 26 VStack(spacing: 0) {
+3 -1
supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift
··· 19 19 } else if let icon = tab.icon { 20 20 Image(systemName: icon) 21 21 .imageScale(.small) 22 - .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 22 + .foregroundStyle( 23 + tab.tintColor?.color ?? (isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 24 + ) 23 25 } 24 26 } 25 27 .frame(
+2 -1
supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift
··· 55 55 isActive: isActive, 56 56 isPressing: isPressing, 57 57 isDragging: isDragging, 58 - isHovering: isHovering 58 + isHovering: isHovering, 59 + tintColor: tab.tintColor 59 60 ) 60 61 .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) 61 62 }
+2 -5
supacodeTests/AppFeatureArchivedSelectionTests.swift
··· 78 78 repositories: repositoriesState, 79 79 settings: SettingsFeature.State() 80 80 ) 81 - appState.runScriptStatusByWorktreeID = [ 82 - activeWorktree.id: true, 83 - archivedWorktree.id: true, 84 - ] 81 + appState.repositories.runScriptWorktreeIDs = [activeWorktree.id, archivedWorktree.id] 85 82 let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 86 83 let store = TestStore(initialState: appState) { 87 84 AppFeature() ··· 94 91 store.exhaustivity = .off 95 92 96 93 await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { 97 - $0.runScriptStatusByWorktreeID = [activeWorktree.id: true] 94 + $0.repositories.runScriptWorktreeIDs = [activeWorktree.id] 98 95 } 99 96 await store.finish() 100 97
+4 -2
supacodeTests/AppFeatureRunScriptTests.swift
··· 60 60 $0.runScriptDraft = "" 61 61 $0.isRunScriptPromptPresented = false 62 62 } 63 - await store.receive(\.runScript) 63 + await store.receive(\.runScript) { 64 + $0.repositories.runScriptWorktreeIDs = [worktree.id] 65 + } 64 66 await store.finish() 65 67 66 - #expect(sent.value == [.runScript(worktree, script: "npm run dev")]) 68 + #expect(sent.value == [.runBlockingScript(worktree, kind: .run, script: "npm run dev")]) 67 69 68 70 let savedRunScript = withDependencies { 69 71 $0.settingsFileStorage = storage.storage
+8 -8
supacodeTests/RepositoriesFeatureTests.swift
··· 1339 1339 TextState("OK") 1340 1340 } 1341 1341 } message: { 1342 - TextState("Script exited with code 7.\nCheck the ARCHIVE SCRIPT tab for details.") 1342 + TextState("Script exited with code 7.\nCheck the Archive Script tab for details.") 1343 1343 } 1344 1344 1345 1345 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { ··· 1475 1475 TextState("OK") 1476 1476 } 1477 1477 } message: { 1478 - TextState("Script failed (exit code 1).\nCheck the ARCHIVE SCRIPT tab for details.") 1478 + TextState("Script failed (exit code 1).\nCheck the Archive Script tab for details.") 1479 1479 } 1480 1480 1481 1481 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) { ··· 1507 1507 TextState("OK") 1508 1508 } 1509 1509 } message: { 1510 - TextState("Permission denied (exit code 126).\nCheck the ARCHIVE SCRIPT tab for details.") 1510 + TextState("Permission denied (exit code 126).\nCheck the Archive Script tab for details.") 1511 1511 } 1512 1512 1513 1513 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 126)) { ··· 1538 1538 TextState("OK") 1539 1539 } 1540 1540 } message: { 1541 - TextState("Command not found (exit code 127).\nCheck the ARCHIVE SCRIPT tab for details.") 1541 + TextState("Command not found (exit code 127).\nCheck the Archive Script tab for details.") 1542 1542 } 1543 1543 1544 1544 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 127)) { ··· 1569 1569 TextState("OK") 1570 1570 } 1571 1571 } message: { 1572 - TextState("Script killed by signal 2 (exit code 130).\nCheck the ARCHIVE SCRIPT tab for details.") 1572 + TextState("Script killed by signal 2 (exit code 130).\nCheck the Archive Script tab for details.") 1573 1573 } 1574 1574 1575 1575 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 130)) { ··· 1600 1600 TextState("OK") 1601 1601 } 1602 1602 } message: { 1603 - TextState("Script killed by signal 9 (exit code 137).\nCheck the ARCHIVE SCRIPT tab for details.") 1603 + TextState("Script killed by signal 9 (exit code 137).\nCheck the Archive Script tab for details.") 1604 1604 } 1605 1605 1606 1606 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 137)) { ··· 1658 1658 TextState("OK") 1659 1659 } 1660 1660 } message: { 1661 - TextState("Script failed (exit code 1).\nCheck the ARCHIVE SCRIPT tab for details.") 1661 + TextState("Script failed (exit code 1).\nCheck the Archive Script tab for details.") 1662 1662 } 1663 1663 } 1664 1664 #expect(store.state.archivedWorktreeIDs.isEmpty) ··· 1803 1803 TextState("OK") 1804 1804 } 1805 1805 } message: { 1806 - TextState("Script exited with code 7.\nCheck the DELETE SCRIPT tab for details.") 1806 + TextState("Script exited with code 7.\nCheck the Delete Script tab for details.") 1807 1807 } 1808 1808 1809 1809 await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) {
+42
supacodeTests/TerminalTabManagerTests.swift
··· 63 63 manager.updateDirty(tabId, isDirty: false) 64 64 #expect(manager.tabs.first?.isDirty == false) 65 65 } 66 + 67 + @Test func createTabWithTintColorSetsColor() { 68 + let manager = TerminalTabManager() 69 + let tabId = manager.createTab(title: "script", icon: "play.fill", tintColor: .green) 70 + let tab = manager.tabs.first { $0.id == tabId } 71 + #expect(tab?.tintColor == .green) 72 + #expect(tab?.icon == "play.fill") 73 + } 74 + 75 + @Test func unlockAndUpdateTitleResetsTabToDefaults() { 76 + let manager = TerminalTabManager() 77 + let tabId = manager.createTab( 78 + title: "Run Script", 79 + icon: "play.fill", 80 + isTitleLocked: true, 81 + tintColor: .green 82 + ) 83 + let before = manager.tabs.first { $0.id == tabId } 84 + #expect(before?.isTitleLocked == true) 85 + #expect(before?.icon == "play.fill") 86 + #expect(before?.tintColor == .green) 87 + 88 + manager.unlockAndUpdateTitle(tabId, title: "wt-1 2") 89 + 90 + let after = manager.tabs.first { $0.id == tabId } 91 + #expect(after?.title == "wt-1 2") 92 + #expect(after?.isTitleLocked == false) 93 + #expect(after?.icon == nil) 94 + #expect(after?.tintColor == nil) 95 + } 96 + 97 + @Test func unlockAndUpdateTitleAllowsSubsequentTitleUpdates() { 98 + let manager = TerminalTabManager() 99 + let tabId = manager.createTab(title: "Run Script", icon: "play.fill", isTitleLocked: true) 100 + 101 + manager.updateTitle(tabId, title: "should be ignored") 102 + #expect(manager.tabs.first { $0.id == tabId }?.title == "Run Script") 103 + 104 + manager.unlockAndUpdateTitle(tabId, title: "wt-1 1") 105 + manager.updateTitle(tabId, title: "new shell title") 106 + #expect(manager.tabs.first { $0.id == tabId }?.title == "new shell title") 107 + } 66 108 }
+86
supacodeTests/WorktreeTerminalManagerTests.swift
··· 469 469 #expect(!state.tabManager.tabs.map(\.id).contains(tabId)) 470 470 } 471 471 472 + @Test func runScriptBlockingScriptTracksRunningState() { 473 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 474 + let worktree = makeWorktree() 475 + 476 + #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) 477 + 478 + manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "echo hi")) 479 + 480 + #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) 481 + } 482 + 483 + @Test func stopRunScriptClosesRunTab() { 484 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 485 + let worktree = makeWorktree() 486 + 487 + manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) 488 + #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == true) 489 + 490 + manager.handleCommand(.stopRunScript(worktree)) 491 + #expect(manager.isBlockingScriptRunning(kind: .run, for: worktree.id) == false) 492 + } 493 + 494 + @Test func runScriptTabTitleResetsAfterSignalInterruption() async { 495 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 496 + let worktree = makeWorktree() 497 + let stream = manager.eventStream() 498 + 499 + manager.handleCommand(.runBlockingScript(worktree, kind: .run, script: "sleep 10")) 500 + 501 + guard let state = manager.stateIfExists(for: worktree.id), 502 + let tabId = state.tabManager.selectedTabId, 503 + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 504 + else { 505 + Issue.record("Expected run script tab and surface") 506 + return 507 + } 508 + 509 + let tab = state.tabManager.tabs.first { $0.id == tabId } 510 + #expect(tab?.title == "Run Script") 511 + #expect(tab?.isTitleLocked == true) 512 + #expect(tab?.tintColor == .green) 513 + 514 + // Simulate Ctrl+C (SIGINT = exit code 130). 515 + surface.bridge.onCommandFinished?(130) 516 + 517 + // Wait for completion event. 518 + _ = await nextEvent(stream) { event in 519 + if case .blockingScriptCompleted = event { return true } 520 + return false 521 + } 522 + 523 + let updatedTab = state.tabManager.tabs.first { $0.id == tabId } 524 + #expect(updatedTab?.isTitleLocked == false) 525 + #expect(updatedTab?.icon == nil) 526 + #expect(updatedTab?.tintColor == nil) 527 + } 528 + 529 + @Test func blockingScriptTabTitleResetsAfterFailure() { 530 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 531 + let worktree = makeWorktree() 532 + 533 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "exit 1")) 534 + 535 + guard let state = manager.stateIfExists(for: worktree.id), 536 + let tabId = state.tabManager.selectedTabId, 537 + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 538 + else { 539 + Issue.record("Expected blocking script tab and surface") 540 + return 541 + } 542 + 543 + let tab = state.tabManager.tabs.first { $0.id == tabId } 544 + #expect(tab?.title == "Archive Script") 545 + #expect(tab?.tintColor == .orange) 546 + 547 + // Title reset happens synchronously in handleBlockingScriptChildExited, 548 + // before the completion callback fires in an async Task. 549 + surface.bridge.onCommandFinished?(1) 550 + surface.bridge.onChildExited?(1) 551 + 552 + let updatedTab = state.tabManager.tabs.first { $0.id == tabId } 553 + #expect(updatedTab?.isTitleLocked == false) 554 + #expect(updatedTab?.icon == nil) 555 + #expect(updatedTab?.tintColor == nil) 556 + } 557 + 472 558 private func makeWorktree() -> Worktree { 473 559 Worktree( 474 560 id: "/tmp/repo/wt-1",