···11import Foundation
2233+/// Who currently owns a tab's icon slot. The precedence chain runs
44+/// `auto < script < user`: stronger owners block weaker writes.
55+///
66+/// - `auto`: nobody has claimed the icon — `CommandIconMap` and other
77+/// auto-detection paths are free to overwrite it.
88+/// - `script`: Run Script's `play.fill` or a Custom Command's configured
99+/// icon. Survives auto-detection so the glyph doesn't flash mid-run.
1010+/// - `user`: the icon picker. Wins over everything until cleared.
1111+///
1212+/// Avoid naming a case `.none` — it collides with `Optional.none` in
1313+/// expressions like `tab?.iconLock == .none`, where Swift would infer
1414+/// the right-hand side as the optional sentinel rather than this case.
1515+enum TerminalTabIconLock: Equatable, Sendable {
1616+ case auto
1717+ case script
1818+ case user
1919+}
2020+321struct TerminalTabItem: Identifiable, Equatable, Sendable {
422 let id: TerminalTabID
523 var title: String
624 var icon: String?
725 var isDirty: Bool
826 var isTitleLocked: Bool
99- var isIconLocked: Bool
2727+ var iconLock: TerminalTabIconLock
10281129 init(
1230 id: TerminalTabID = TerminalTabID(),
···1432 icon: String?,
1533 isDirty: Bool = false,
1634 isTitleLocked: Bool = false,
1717- isIconLocked: Bool = false
3535+ iconLock: TerminalTabIconLock = .auto
1836 ) {
1937 self.id = id
2038 self.title = title
2139 self.icon = icon
2240 self.isDirty = isDirty
2341 self.isTitleLocked = isTitleLocked
2424- self.isIconLocked = isIconLocked
4242+ self.iconLock = iconLock
2543 }
2644}
···259259 workingDirectoryOverride: nil
260260 )
261261 )
262262+ if let tabId {
263263+ // Lock in the play glyph as a script-level override so OSC-2
264264+ // titles emitted by the script (e.g. `npm run dev`) can't swap
265265+ // the icon out from under it.
266266+ tabManager.setScriptIcon(tabId, icon: "play.fill")
267267+ }
262268 setRunScriptTabId(tabId)
263269 return tabId
264270 }
···562568 pendingCustomCommands[surfaceId] = name
563569 }
564570571571+ /// Pin a Custom Command's configured icon onto its host tab so the
572572+ /// auto-detector can't swap it out when the script's OSC-2 title
573573+ /// matches a known command. Yields to a user-set icon lock — manual
574574+ /// picker selections always win.
575575+ func applyCustomCommandIcon(_ icon: String, surfaceId: UUID) {
576576+ let trimmed = icon.trimmingCharacters(in: .whitespacesAndNewlines)
577577+ guard !trimmed.isEmpty else { return }
578578+ guard let tabId = tabId(containing: surfaceId) else { return }
579579+ tabManager.setScriptIcon(tabId, icon: trimmed)
580580+ }
581581+565582 // Short delay lets the user see the final output before the pane disappears.
566583 private static let autoCloseDelay: Duration = .milliseconds(800)
567584···735752 }
736753 // Skip title/icon for blocking-script tabs as they are transient.
737754 // Persist the icon only when the user has explicitly overridden it; otherwise
738738- // restore should pick up the current default ("terminal").
755755+ // restore should pick up the current default ("terminal") or auto-detection.
739756 let isBlockingScriptTab = tab.id == runScriptTabId
740740- let snapshotIcon: String? = (isBlockingScriptTab || !tab.isIconLocked) ? nil : tab.icon
757757+ let snapshotIcon: String? = (isBlockingScriptTab || tab.iconLock != .user) ? nil : tab.icon
741758 snapshotTabs.append(
742759 TerminalLayoutSnapshotPayload.SnapshotTab(
743760 tabID: tab.id.rawValue.uuidString,
···831848 title: entry.snapshotTab.title ?? "\(worktree.name) \(index + 1)",
832849 icon: entry.snapshotTab.icon ?? "terminal",
833850 isTitleLocked: entry.snapshotTab.title != nil,
834834- isIconLocked: entry.snapshotTab.icon != nil
851851+ iconLock: entry.snapshotTab.icon != nil ? .user : .auto
835852 )
836853 )
837854 }
···14951512 return false
14961513 }
1497151414981498- /// Apply an already-resolved icon to the tab. Honours focus and
14991499- /// user-icon-lock; encodes the icon through `storageString` so
15001500- /// `assetName`-bearing entries pick up the `@asset:` marker the
15011501- /// renderers parse via `ResolvedTabIcon`.
15151515+ /// Apply an already-resolved icon to the tab. Honours focus, the user
15161516+ /// icon lock, and the Run Script / Custom Command override; encodes
15171517+ /// the icon through `storageString` so `assetName`-bearing entries
15181518+ /// pick up the `@asset:` marker the renderers parse via
15191519+ /// `ResolvedTabIcon`.
15021520 private func applyResolvedIcon(
15031521 _ icon: TabIconSource,
15041522 surfaceId: UUID,
···15101528 // user is currently looking at.
15111529 guard focusedSurfaceIdByTab[tabId] == surfaceId else { return }
15121530 guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return }
15131513- guard !tab.isIconLocked else { return }
15311531+ guard tab.iconLock == .auto else { return }
15141532 let serialised = icon.storageString
15151533 guard tab.icon != serialised else { return }
15161534 tabManager.updateIcon(tabId, icon: serialised)