native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #245 from onevcat/feat/script-icon-priority

feat(tab-icon): pin Run Script and Custom Command icons over auto-detection

authored by

Wei Wang and committed by
GitHub
25e779bd ceef897a

+243 -31
+4 -2
supacode/Clients/Terminal/TerminalClient.swift
··· 13 13 input: String, 14 14 runSetupScriptIfNew: Bool, 15 15 autoCloseOnSuccess: Bool, 16 - customCommandName: String? = nil 16 + customCommandName: String? = nil, 17 + customCommandIcon: String? = nil 17 18 ) 18 19 case createSplitWithInput( 19 20 Worktree, 20 21 direction: UserCustomSplitDirection, 21 22 input: String, 22 23 autoCloseOnSuccess: Bool, 23 - customCommandName: String? = nil 24 + customCommandName: String? = nil, 25 + customCommandIcon: String? = nil 24 26 ) 25 27 case createTabInDirectory(Worktree, directory: URL) 26 28 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool)
+13 -2
supacode/Features/App/Reducer/AppFeature.swift
··· 645 645 let command = customCommand.command 646 646 let closeOnSuccess = customCommand.closeOnSuccess 647 647 let commandName = customCommand.resolvedTitle 648 + // Treat the model's "terminal" placeholder (and an empty value) 649 + // as "no icon configured", so the auto-detector can still brand 650 + // the tab from the command itself. Anything else is a deliberate 651 + // user pick and gets pinned for the duration of the run. 652 + let commandIcon: String? = { 653 + let trimmed = customCommand.systemImage.trimmingCharacters(in: .whitespacesAndNewlines) 654 + guard !trimmed.isEmpty, trimmed != "terminal" else { return nil } 655 + return trimmed 656 + }() 648 657 switch customCommand.execution { 649 658 case .shellScript: 650 659 return .run { _ in ··· 654 663 input: command, 655 664 runSetupScriptIfNew: false, 656 665 autoCloseOnSuccess: closeOnSuccess, 657 - customCommandName: commandName 666 + customCommandName: commandName, 667 + customCommandIcon: commandIcon 658 668 ) 659 669 ) 660 670 } ··· 667 677 direction: direction, 668 678 input: command, 669 679 autoCloseOnSuccess: closeOnSuccess, 670 - customCommandName: commandName 680 + customCommandName: commandName, 681 + customCommandIcon: commandIcon 671 682 ) 672 683 ) 673 684 }
+18 -6
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 53 53 case .createTab(let worktree, let runSetupScriptIfNew): 54 54 Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew) } 55 55 case .createTabWithInput( 56 - let worktree, let input, let runSetupScriptIfNew, let autoCloseOnSuccess, let customCommandName): 56 + let worktree, let input, let runSetupScriptIfNew, let autoCloseOnSuccess, let customCommandName, 57 + let customCommandIcon): 57 58 Task { 58 59 createTabAsync( 59 60 in: worktree, 60 61 runSetupScriptIfNew: runSetupScriptIfNew, 61 62 initialInput: input, 62 63 autoCloseOnSuccess: autoCloseOnSuccess, 63 - customCommandName: customCommandName 64 + customCommandName: customCommandName, 65 + customCommandIcon: customCommandIcon 64 66 ) 65 67 } 66 - case .createSplitWithInput(let worktree, let direction, let input, let autoCloseOnSuccess, let customCommandName): 68 + case .createSplitWithInput( 69 + let worktree, let direction, let input, let autoCloseOnSuccess, let customCommandName, let customCommandIcon): 67 70 Task { 68 71 createSplitAsync( 69 72 in: worktree, 70 73 direction: direction, 71 74 initialInput: input, 72 75 autoCloseOnSuccess: autoCloseOnSuccess, 73 - customCommandName: customCommandName 76 + customCommandName: customCommandName, 77 + customCommandIcon: customCommandIcon 74 78 ) 75 79 } 76 80 case .createTabInDirectory(let worktree, let directory): ··· 268 272 initialInput: String? = nil, 269 273 workingDirectory: URL? = nil, 270 274 autoCloseOnSuccess: Bool = false, 271 - customCommandName: String? = nil 275 + customCommandName: String? = nil, 276 + customCommandIcon: String? = nil 272 277 ) { 273 278 let state = state(for: worktree) { runSetupScriptIfNew } 274 279 let setupScript: String? ··· 292 297 } 293 298 if let customCommandName { 294 299 state.markSurfaceForCustomCommand(surfaceId, name: customCommandName) 300 + } 301 + if let customCommandIcon { 302 + state.applyCustomCommandIcon(customCommandIcon, surfaceId: surfaceId) 295 303 } 296 304 } 297 305 } ··· 301 309 direction: UserCustomSplitDirection, 302 310 initialInput: String, 303 311 autoCloseOnSuccess: Bool, 304 - customCommandName: String? = nil 312 + customCommandName: String? = nil, 313 + customCommandIcon: String? = nil 305 314 ) { 306 315 let state = state(for: worktree) 307 316 guard ··· 317 326 } 318 327 if let customCommandName { 319 328 state.markSurfaceForCustomCommand(newSurfaceId, name: customCommandName) 329 + } 330 + if let customCommandIcon { 331 + state.applyCustomCommandIcon(customCommandIcon, surfaceId: newSurfaceId) 320 332 } 321 333 } 322 334
+21 -3
supacode/Features/Terminal/Models/TerminalTabItem.swift
··· 1 1 import Foundation 2 2 3 + /// Who currently owns a tab's icon slot. The precedence chain runs 4 + /// `auto < script < user`: stronger owners block weaker writes. 5 + /// 6 + /// - `auto`: nobody has claimed the icon — `CommandIconMap` and other 7 + /// auto-detection paths are free to overwrite it. 8 + /// - `script`: Run Script's `play.fill` or a Custom Command's configured 9 + /// icon. Survives auto-detection so the glyph doesn't flash mid-run. 10 + /// - `user`: the icon picker. Wins over everything until cleared. 11 + /// 12 + /// Avoid naming a case `.none` — it collides with `Optional.none` in 13 + /// expressions like `tab?.iconLock == .none`, where Swift would infer 14 + /// the right-hand side as the optional sentinel rather than this case. 15 + enum TerminalTabIconLock: Equatable, Sendable { 16 + case auto 17 + case script 18 + case user 19 + } 20 + 3 21 struct TerminalTabItem: Identifiable, Equatable, Sendable { 4 22 let id: TerminalTabID 5 23 var title: String 6 24 var icon: String? 7 25 var isDirty: Bool 8 26 var isTitleLocked: Bool 9 - var isIconLocked: Bool 27 + var iconLock: TerminalTabIconLock 10 28 11 29 init( 12 30 id: TerminalTabID = TerminalTabID(), ··· 14 32 icon: String?, 15 33 isDirty: Bool = false, 16 34 isTitleLocked: Bool = false, 17 - isIconLocked: Bool = false 35 + iconLock: TerminalTabIconLock = .auto 18 36 ) { 19 37 self.id = id 20 38 self.title = title 21 39 self.icon = icon 22 40 self.isDirty = isDirty 23 41 self.isTitleLocked = isTitleLocked 24 - self.isIconLocked = isIconLocked 42 + self.iconLock = iconLock 25 43 } 26 44 }
+18 -3
supacode/Features/Terminal/Models/TerminalTabManager.swift
··· 41 41 tabs[index].isTitleLocked = false 42 42 } 43 43 44 + /// Auto-detection write path (e.g. `CommandIconMap`). Only applies 45 + /// when nothing has claimed the icon slot. 44 46 func updateIcon(_ id: TerminalTabID, icon: String?) { 45 47 guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 46 - guard !tabs[index].isIconLocked else { return } 48 + guard tabs[index].iconLock == .auto else { return } 47 49 tabs[index].icon = icon 48 50 } 49 51 52 + /// User picker path. Always wins, transitioning the slot to `.user`. 50 53 func overrideIcon(_ id: TerminalTabID, icon: String) { 51 54 guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 52 55 tabs[index].icon = icon 53 - tabs[index].isIconLocked = true 56 + tabs[index].iconLock = .user 54 57 } 55 58 59 + /// "Reset to default" from the icon picker. Drops back to `.none` 60 + /// so the next auto-detected match can take over. 56 61 func clearIconOverride(_ id: TerminalTabID) { 57 62 guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 58 - tabs[index].isIconLocked = false 63 + tabs[index].iconLock = .auto 64 + } 65 + 66 + /// Run Script / Custom Command write path. Pins the icon to `.script` 67 + /// — strong enough to block auto-detection, weak enough to yield to 68 + /// a user-set `.user` lock. 69 + func setScriptIcon(_ id: TerminalTabID, icon: String) { 70 + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } 71 + guard tabs[index].iconLock != .user else { return } 72 + tabs[index].icon = icon 73 + tabs[index].iconLock = .script 59 74 } 60 75 61 76 func updateDirty(_ id: TerminalTabID, isDirty: Bool) {
+26 -8
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 259 259 workingDirectoryOverride: nil 260 260 ) 261 261 ) 262 + if let tabId { 263 + // Lock in the play glyph as a script-level override so OSC-2 264 + // titles emitted by the script (e.g. `npm run dev`) can't swap 265 + // the icon out from under it. 266 + tabManager.setScriptIcon(tabId, icon: "play.fill") 267 + } 262 268 setRunScriptTabId(tabId) 263 269 return tabId 264 270 } ··· 562 568 pendingCustomCommands[surfaceId] = name 563 569 } 564 570 571 + /// Pin a Custom Command's configured icon onto its host tab so the 572 + /// auto-detector can't swap it out when the script's OSC-2 title 573 + /// matches a known command. Yields to a user-set icon lock — manual 574 + /// picker selections always win. 575 + func applyCustomCommandIcon(_ icon: String, surfaceId: UUID) { 576 + let trimmed = icon.trimmingCharacters(in: .whitespacesAndNewlines) 577 + guard !trimmed.isEmpty else { return } 578 + guard let tabId = tabId(containing: surfaceId) else { return } 579 + tabManager.setScriptIcon(tabId, icon: trimmed) 580 + } 581 + 565 582 // Short delay lets the user see the final output before the pane disappears. 566 583 private static let autoCloseDelay: Duration = .milliseconds(800) 567 584 ··· 735 752 } 736 753 // Skip title/icon for blocking-script tabs as they are transient. 737 754 // Persist the icon only when the user has explicitly overridden it; otherwise 738 - // restore should pick up the current default ("terminal"). 755 + // restore should pick up the current default ("terminal") or auto-detection. 739 756 let isBlockingScriptTab = tab.id == runScriptTabId 740 - let snapshotIcon: String? = (isBlockingScriptTab || !tab.isIconLocked) ? nil : tab.icon 757 + let snapshotIcon: String? = (isBlockingScriptTab || tab.iconLock != .user) ? nil : tab.icon 741 758 snapshotTabs.append( 742 759 TerminalLayoutSnapshotPayload.SnapshotTab( 743 760 tabID: tab.id.rawValue.uuidString, ··· 831 848 title: entry.snapshotTab.title ?? "\(worktree.name) \(index + 1)", 832 849 icon: entry.snapshotTab.icon ?? "terminal", 833 850 isTitleLocked: entry.snapshotTab.title != nil, 834 - isIconLocked: entry.snapshotTab.icon != nil 851 + iconLock: entry.snapshotTab.icon != nil ? .user : .auto 835 852 ) 836 853 ) 837 854 } ··· 1495 1512 return false 1496 1513 } 1497 1514 1498 - /// Apply an already-resolved icon to the tab. Honours focus and 1499 - /// user-icon-lock; encodes the icon through `storageString` so 1500 - /// `assetName`-bearing entries pick up the `@asset:` marker the 1501 - /// renderers parse via `ResolvedTabIcon`. 1515 + /// Apply an already-resolved icon to the tab. Honours focus, the user 1516 + /// icon lock, and the Run Script / Custom Command override; encodes 1517 + /// the icon through `storageString` so `assetName`-bearing entries 1518 + /// pick up the `@asset:` marker the renderers parse via 1519 + /// `ResolvedTabIcon`. 1502 1520 private func applyResolvedIcon( 1503 1521 _ icon: TabIconSource, 1504 1522 surfaceId: UUID, ··· 1510 1528 // user is currently looking at. 1511 1529 guard focusedSurfaceIdByTab[tabId] == surfaceId else { return } 1512 1530 guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } 1513 - guard !tab.isIconLocked else { return } 1531 + guard tab.iconLock == .auto else { return } 1514 1532 let serialised = icon.storageString 1515 1533 guard tab.icon != serialised else { return } 1516 1534 tabManager.updateIcon(tabId, icon: serialised)
+97 -5
supacodeTests/AppFeatureCustomCommandTests.swift
··· 42 42 input: "swift test", 43 43 runSetupScriptIfNew: false, 44 44 autoCloseOnSuccess: false, 45 - customCommandName: "Test" 45 + customCommandName: "Test", 46 + customCommandIcon: "checkmark.circle" 46 47 ), 47 48 ], 48 49 ) ··· 119 120 direction: .down, 120 121 input: "tail -f logs", 121 122 autoCloseOnSuccess: false, 122 - customCommandName: "Tail" 123 + customCommandName: "Tail", 124 + customCommandIcon: "doc.text" 123 125 ), 124 126 ], 125 127 ) ··· 171 173 input: "make build", 172 174 runSetupScriptIfNew: false, 173 175 autoCloseOnSuccess: true, 174 - customCommandName: "Build" 176 + customCommandName: "Build", 177 + customCommandIcon: "hammer" 175 178 ), 176 179 .createSplitWithInput( 177 180 worktree, 178 181 direction: .right, 179 182 input: "make lint", 180 183 autoCloseOnSuccess: true, 181 - customCommandName: "Lint" 184 + customCommandName: "Lint", 185 + customCommandIcon: "checkmark" 182 186 ), 183 187 ], 184 188 ) ··· 286 290 input: "echo five", 287 291 runSetupScriptIfNew: false, 288 292 autoCloseOnSuccess: false, 289 - customCommandName: "Five" 293 + customCommandName: "Five", 294 + customCommandIcon: "5.circle" 295 + ), 296 + ], 297 + ) 298 + } 299 + 300 + @Test(.dependencies) func defaultTerminalIconIsTreatedAsUnsetForAutoDetection() async { 301 + let worktree = makeWorktree() 302 + let sent = LockIsolated<[TerminalClient.Command]>([]) 303 + var state = AppFeature.State( 304 + repositories: makeRepositoriesState(worktree: worktree), 305 + settings: SettingsFeature.State() 306 + ) 307 + state.selectedCustomCommands = [ 308 + UserCustomCommand( 309 + title: "Default", 310 + systemImage: "terminal", 311 + command: "npm test", 312 + execution: .shellScript, 313 + shortcut: nil, 314 + ), 315 + ] 316 + 317 + let store = TestStore(initialState: state) { 318 + AppFeature() 319 + } withDependencies: { 320 + $0.terminalClient.send = { command in 321 + sent.withValue { $0.append(command) } 322 + } 323 + } 324 + 325 + await store.send(.runCustomCommand(0)) 326 + await store.finish() 327 + 328 + // The model's "terminal" placeholder should not be pinned as a 329 + // script icon — the auto-detector should remain free to brand the 330 + // tab from the executed command itself. 331 + #expect( 332 + sent.value == [ 333 + .createTabWithInput( 334 + worktree, 335 + input: "npm test", 336 + runSetupScriptIfNew: false, 337 + autoCloseOnSuccess: false, 338 + customCommandName: "Default", 339 + customCommandIcon: nil 340 + ), 341 + ], 342 + ) 343 + } 344 + 345 + @Test(.dependencies) func emptyIconIsTreatedAsUnsetForAutoDetection() async { 346 + let worktree = makeWorktree() 347 + let sent = LockIsolated<[TerminalClient.Command]>([]) 348 + var state = AppFeature.State( 349 + repositories: makeRepositoriesState(worktree: worktree), 350 + settings: SettingsFeature.State() 351 + ) 352 + state.selectedCustomCommands = [ 353 + UserCustomCommand( 354 + title: "Blank", 355 + systemImage: " ", 356 + command: "swift test", 357 + execution: .shellScript, 358 + shortcut: nil, 359 + ), 360 + ] 361 + 362 + let store = TestStore(initialState: state) { 363 + AppFeature() 364 + } withDependencies: { 365 + $0.terminalClient.send = { command in 366 + sent.withValue { $0.append(command) } 367 + } 368 + } 369 + 370 + await store.send(.runCustomCommand(0)) 371 + await store.finish() 372 + 373 + #expect( 374 + sent.value == [ 375 + .createTabWithInput( 376 + worktree, 377 + input: "swift test", 378 + runSetupScriptIfNew: false, 379 + autoCloseOnSuccess: false, 380 + customCommandName: "Blank", 381 + customCommandIcon: nil 290 382 ), 291 383 ], 292 384 )
+46 -2
supacodeTests/TerminalTabManagerTests.swift
··· 69 69 let tabId = manager.createTab(title: "one", icon: "terminal") 70 70 manager.overrideIcon(tabId, icon: "sparkles") 71 71 #expect(manager.tabs.first?.icon == "sparkles") 72 - #expect(manager.tabs.first?.isIconLocked == true) 72 + #expect(manager.tabs.first?.iconLock == .user) 73 73 } 74 74 75 75 @Test func updateIconRespectsLock() { ··· 85 85 let tabId = manager.createTab(title: "one", icon: "terminal") 86 86 manager.overrideIcon(tabId, icon: "sparkles") 87 87 manager.clearIconOverride(tabId) 88 - #expect(manager.tabs.first?.isIconLocked == false) 88 + #expect(manager.tabs.first?.iconLock == .auto) 89 89 manager.updateIcon(tabId, icon: "play.fill") 90 90 #expect(manager.tabs.first?.icon == "play.fill") 91 + } 92 + 93 + @Test func setScriptIconAppliesAndFlags() { 94 + let manager = TerminalTabManager() 95 + let tabId = manager.createTab(title: "one", icon: "terminal") 96 + manager.setScriptIcon(tabId, icon: "play.fill") 97 + #expect(manager.tabs.first?.icon == "play.fill") 98 + #expect(manager.tabs.first?.iconLock == .script) 99 + } 100 + 101 + @Test func updateIconYieldsToScriptLock() { 102 + let manager = TerminalTabManager() 103 + let tabId = manager.createTab(title: "one", icon: "terminal") 104 + manager.setScriptIcon(tabId, icon: "play.fill") 105 + manager.updateIcon(tabId, icon: "@asset:Npm") 106 + #expect(manager.tabs.first?.icon == "play.fill") 107 + } 108 + 109 + @Test func userOverrideSupersedesScriptLock() { 110 + let manager = TerminalTabManager() 111 + let tabId = manager.createTab(title: "one", icon: "terminal") 112 + manager.setScriptIcon(tabId, icon: "play.fill") 113 + manager.overrideIcon(tabId, icon: "sparkles") 114 + #expect(manager.tabs.first?.icon == "sparkles") 115 + #expect(manager.tabs.first?.iconLock == .user) 116 + } 117 + 118 + @Test func setScriptIconYieldsToUserLock() { 119 + let manager = TerminalTabManager() 120 + let tabId = manager.createTab(title: "one", icon: "terminal") 121 + manager.overrideIcon(tabId, icon: "sparkles") 122 + manager.setScriptIcon(tabId, icon: "play.fill") 123 + #expect(manager.tabs.first?.icon == "sparkles") 124 + #expect(manager.tabs.first?.iconLock == .user) 125 + } 126 + 127 + @Test func clearIconOverrideReleasesScriptLock() { 128 + let manager = TerminalTabManager() 129 + let tabId = manager.createTab(title: "one", icon: "terminal") 130 + manager.setScriptIcon(tabId, icon: "play.fill") 131 + manager.clearIconOverride(tabId) 132 + #expect(manager.tabs.first?.iconLock == .auto) 133 + manager.updateIcon(tabId, icon: "@asset:Npm") 134 + #expect(manager.tabs.first?.icon == "@asset:Npm") 91 135 } 92 136 }