native macOS codings agent orchestrator
6
fork

Configure Feed

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

feat(terminal): auto-detect tab icon from running command

The tab icon was hard-coded to `terminal`, which made multiple tabs
visually indistinguishable. Detect the running command from the OSC 2
title set by the shell's `preexec` (and from TUI tools that rewrite
their own title) and pick a per-command icon.

Detector pipeline (`WorktreeTerminalState`):

- Per-surface debounce on title changes — a candidate must hold the
title for ~1.5 s before being treated as a long-running command,
so transient titles set by `ls`/`git status` etc. don't cause icon
flicker.
- Idle-prompt suppression: shell prompts (`user@host:path`,
`~/path`, …) are filtered both by shape heuristics (bootstrap) and
by learning the first title that arrives after each
`command_finished` (per-surface adaptive set).
- Per-cycle dedupe: once a `[live]` icon is reported for a surface,
further title changes within the same command run (claude's
spinner cycling through `✳ Claude Code` → `⠐ Claude Code`) are
ignored until the next `command_finished` clears the guard.
- Substring path: TUI tools that rewrite their own title bypass the
debounce and the per-cycle guard so a branded title can refine the
icon set by the initial command name.
- Selection-2 semantics: the icon stays after the command exits, so
the tab keeps a long-lived "what is this for" hint. User-locked
icons (`isIconLocked`) and non-focused surfaces in a split tab are
never overridden.

Mapping & extensibility:

- `TabIconSource` is a struct with a required `systemSymbol` and an
optional `assetName`. `assetName` is the reserved entry point for
per-tool PNG/SVG artwork (claude, docker, npm, …); rendering still
paints `systemSymbol` until the icon-rendering call sites are
taught to read `assetName` (recipe documented on the type).
- `CommandIconMap` ships ~30 first-token entries (editors, package
managers, build tools, containers, network, system viewers, db,
logs, coding agents) and a substring entry for `claude code`.

The detector currently emits log lines under the `CommandDetect`
category to make further tuning observable.

onevcat 75a8531a 942a5609

+448
+114
supacode/Features/Terminal/Models/CommandIconMap.swift
··· 1 + import Foundation 2 + 3 + /// Resolves a tab icon from a command title surfaced by the 4 + /// auto-detector (typically the OSC 2 title set by the shell's 5 + /// `preexec`, or one a TUI rewrites on launch). 6 + /// 7 + /// Two lookup paths exist: 8 + /// 9 + /// - `iconForFirstToken(_:)`: case-insensitive match on the *first 10 + /// whitespace-delimited token*. Used by the debounce path so short 11 + /// commands (`ls`, `git status`) never trigger an icon swap. 12 + /// Examples: `"swift build"` and `"swift test"` route through the 13 + /// `swift` entry; `"claude"` routes through `claude`. 14 + /// 15 + /// - `iconForSubstring(_:)`: case-insensitive substring match against 16 + /// the entire title. Used as an immediate-apply path that bypasses 17 + /// the debounce, intended for TUI tools that overwrite their own 18 + /// title after launch (e.g. `claude` → `✳ Claude Code`). The 19 + /// substring rule wins so a TUI's branded title can refine the 20 + /// icon set by the initial command name. 21 + /// 22 + /// Both return `nil` when nothing matches; the auto-detector then 23 + /// leaves the tab's existing icon untouched (selection-2 semantics — 24 + /// a previously-detected icon is preserved across unknown commands). 25 + enum CommandIconMap { 26 + static func iconForFirstToken(_ title: String) -> TabIconSource? { 27 + let token = firstToken(of: title).lowercased() 28 + return firstTokenMapping[token] 29 + } 30 + 31 + static func iconForSubstring(_ title: String) -> TabIconSource? { 32 + let lowered = title.lowercased() 33 + for (needle, icon) in substringMapping where lowered.contains(needle) { 34 + return icon 35 + } 36 + return nil 37 + } 38 + 39 + private static func firstToken(of title: String) -> String { 40 + title 41 + .split(separator: " ", omittingEmptySubsequences: true) 42 + .first 43 + .map(String.init) 44 + ?? title 45 + } 46 + 47 + /// First-token table. Grouped by category and alphabetised within 48 + /// each group. SF Symbols only at this layer keep things glanceable 49 + /// before asset rendering is wired; entries that ship branded 50 + /// artwork should add `assetName:` and let renderers prefer it. 51 + private static let firstTokenMapping: [String: TabIconSource] = [ 52 + // Coding agents — the SF Symbol is just a placeholder so the icon 53 + // is non-blank; the real branded artwork is picked up via the 54 + // substring path (which routes the post-launch TUI title to an 55 + // assetName entry). 56 + "claude": TabIconSource(systemSymbol: "sparkle", assetName: "Claude Code"), 57 + "codex": TabIconSource(systemSymbol: "sparkle"), 58 + "aider": TabIconSource(systemSymbol: "sparkle"), 59 + 60 + // Editors / pagers 61 + "vim": TabIconSource(systemSymbol: "pencil.and.scribble"), 62 + "nvim": TabIconSource(systemSymbol: "pencil.and.scribble"), 63 + "nano": TabIconSource(systemSymbol: "pencil.and.scribble"), 64 + 65 + // Package managers / JS runtimes 66 + "npm": TabIconSource(systemSymbol: "shippingbox"), 67 + "pnpm": TabIconSource(systemSymbol: "shippingbox"), 68 + "yarn": TabIconSource(systemSymbol: "shippingbox"), 69 + "bun": TabIconSource(systemSymbol: "shippingbox"), 70 + 71 + // VCS 72 + "git": TabIconSource(systemSymbol: "arrow.triangle.branch"), 73 + "gh": TabIconSource(systemSymbol: "arrow.triangle.branch"), 74 + 75 + // Build tools 76 + "make": TabIconSource(systemSymbol: "hammer"), 77 + "swift": TabIconSource(systemSymbol: "hammer"), 78 + "cargo": TabIconSource(systemSymbol: "hammer"), 79 + "xcodebuild": TabIconSource(systemSymbol: "hammer"), 80 + "gradle": TabIconSource(systemSymbol: "hammer"), 81 + 82 + // Container / orchestration 83 + "docker": TabIconSource(systemSymbol: "shippingbox.fill"), 84 + "kubectl": TabIconSource(systemSymbol: "shippingbox.fill"), 85 + "podman": TabIconSource(systemSymbol: "shippingbox.fill"), 86 + 87 + // Network / remote 88 + "ssh": TabIconSource(systemSymbol: "network"), 89 + "mosh": TabIconSource(systemSymbol: "network"), 90 + "curl": TabIconSource(systemSymbol: "network"), 91 + 92 + // Process / system viewers 93 + "htop": TabIconSource(systemSymbol: "waveform.path.ecg"), 94 + "btop": TabIconSource(systemSymbol: "waveform.path.ecg"), 95 + "top": TabIconSource(systemSymbol: "waveform.path.ecg"), 96 + 97 + // Database REPLs 98 + "psql": TabIconSource(systemSymbol: "cylinder.split.1x2"), 99 + "mysql": TabIconSource(systemSymbol: "cylinder.split.1x2"), 100 + "sqlite3": TabIconSource(systemSymbol: "cylinder.split.1x2"), 101 + 102 + // Logs / streams 103 + "tail": TabIconSource(systemSymbol: "text.justifyleft"), 104 + "journalctl": TabIconSource(systemSymbol: "text.justifyleft"), 105 + ] 106 + 107 + /// Substring patterns for TUI tools that rewrite their own title 108 + /// after launch. Needles are matched case-insensitively against the 109 + /// full title; the first match wins, so list more specific 110 + /// patterns earlier when conflicts arise. 111 + private static let substringMapping: [(needle: String, icon: TabIconSource)] = [ 112 + ("claude code", TabIconSource(systemSymbol: "sparkle", assetName: "Claude Code")), 113 + ] 114 + }
+32
supacode/Features/Terminal/Models/TabIconSource.swift
··· 1 + import Foundation 2 + 3 + /// Specifies the artwork to use for a tab icon. `systemSymbol` is the 4 + /// always-renderable SF Symbol that the current call sites paint 5 + /// (`Image(systemName:)` in `ShelfSpineView` and 6 + /// `TerminalTabLabelView`). `assetName` is an optional, more specific 7 + /// PNG/SVG shipped in the asset catalog — reserved for tools where 8 + /// stock SF Symbols don't read well (claude, docker, npm, …). 9 + /// 10 + /// Today no call site reads `assetName`, so `assetName`-bearing 11 + /// entries gracefully degrade to their `systemSymbol`. To wire real 12 + /// asset rendering: 13 + /// 1. Ship the artwork in the app's asset catalog. 14 + /// 2. Add an `assetName:` argument on the relevant `CommandIconMap` 15 + /// entry (or use it on a new `TabIconSource(systemSymbol:assetName:)`). 16 + /// 3. Extend the icon-rendering call sites to prefer `assetName` 17 + /// when present (`Image(_:)`) and fall back to `systemSymbol` 18 + /// when the asset is missing. 19 + struct TabIconSource: Equatable, Hashable, Sendable { 20 + /// SF Symbol drawn via `Image(systemName:)`. Always set so callers 21 + /// have something renderable even before asset rendering is wired. 22 + let systemSymbol: String 23 + /// Asset catalog entry, if any. Renderers that support assets 24 + /// should prefer this when set; renderers that don't will keep 25 + /// painting `systemSymbol` and the user gets a graceful fallback. 26 + let assetName: String? 27 + 28 + init(systemSymbol: String, assetName: String? = nil) { 29 + self.systemSymbol = systemSymbol 30 + self.assetName = assetName 31 + } 32 + }
+302
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 6 6 import Sharing 7 7 8 8 private let terminalStateLogger = SupaLogger("TerminalState") 9 + private let commandDetectLogger = SupaLogger("CommandDetect") 9 10 10 11 @MainActor 11 12 @Observable ··· 13 14 struct SurfaceActivity: Equatable { 14 15 let isVisible: Bool 15 16 let isFocused: Bool 17 + } 18 + 19 + /// Pending state for the title-debounce-based command detector. 20 + /// `task` fires after `commandDetectThresholdMs` if no further title 21 + /// change arrives — at which point `title` is treated as a likely 22 + /// long-running command. Cancelled when a new title arrives, when 23 + /// the surface goes away, or when `command_finished` is received. 24 + fileprivate struct CommandDetectorPending { 25 + let title: String 26 + let startedAt: ContinuousClock.Instant 27 + let task: Task<Void, Never> 16 28 } 17 29 18 30 let tabManager: TerminalTabManager ··· 50 62 /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success 51 63 /// toast when the command exits with code 0. One-shot: removed on the first finish event. 52 64 private var pendingCustomCommands: [UUID: String] = [:] 65 + /// Per-surface pending state for the title-debounce-based tab-icon 66 + /// auto-detection. Research/log-only for now: records the most recent 67 + /// non-empty OSC 2 title and fires a timer after 68 + /// `commandDetectThresholdMs` to distinguish long-running commands 69 + /// (worth reflecting in the tab icon) from short ones (e.g. `ls`, 70 + /// `git status`) that would just cause icon flicker. 71 + private var commandDetectorPendingBySurface: [UUID: CommandDetectorPending] = [:] 72 + /// The title that was the active candidate immediately before the 73 + /// current one — i.e. the title set by `preexec` for the command 74 + /// that just ran. By the time `onCommandFinished` arrives, `precmd` 75 + /// has already overwritten the live title with the idle prompt, so 76 + /// the "current" candidate would be useless for naming the just- 77 + /// finished command. This slot preserves the real command name. 78 + /// Cleared each time a `command_finished` event consumes it. 79 + private var lastObservedTitleBySurface: [UUID: String] = [:] 80 + /// Per-surface set of titles known to be the shell's idle prompt 81 + /// (between commands). Populated by observing the first title that 82 + /// arrives after each `command_finished`: that title is reliably 83 + /// `precmd`-set, i.e. an idle prompt. Subsequent occurrences of any 84 + /// learned title skip the debounce timer entirely so they cannot 85 + /// trigger spurious `[live]` "long-running command" reports. 86 + private var learnedIdleTitlesBySurface: [UUID: Set<String>] = [:] 87 + /// Surfaces whose next title-change event should be added to 88 + /// `learnedIdleTitlesBySurface`. Set by `command_finished`, consumed 89 + /// by the next title arrival. 90 + private var awaitingIdleTitleLearningBySurface: Set<UUID> = [] 91 + /// Per-surface guard recording the title that has already been 92 + /// reported as the running command for the current command cycle. 93 + /// While set, further title changes for the same surface skip the 94 + /// debounce timer — a long-running TUI (claude, vim) frequently 95 + /// rewrites its title (`claude` → `✳ Claude Code` → `⠐ Claude Code`) 96 + /// and we only want to surface the initial command name once per 97 + /// run. Cleared on `command_finished`. 98 + private var reportedRunningCommandBySurface: [UUID: String] = [:] 53 99 var hasUnseenNotification: Bool { 54 100 notifications.contains { !$0.isRead } 55 101 } ··· 525 571 } catch { 526 572 newSurface.closeSurface() 527 573 surfaces.removeValue(forKey: newSurface.id) 574 + cleanupCommandDetectorState(forSurfaceId: newSurface.id) 528 575 return nil 529 576 } 530 577 } ··· 593 640 } catch { 594 641 newSurface.closeSurface() 595 642 surfaces.removeValue(forKey: newSurface.id) 643 + cleanupCommandDetectorState(forSurfaceId: newSurface.id) 596 644 597 645 return false 598 646 } ··· 987 1035 if self.focusedSurfaceIdByTab[tabId] == view.id { 988 1036 self.tabManager.updateTitle(tabId, title: title) 989 1037 } 1038 + self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) 990 1039 } 991 1040 view.bridge.onSplitAction = { [weak self, weak view] action in 992 1041 guard let self, let view else { return false } ··· 1367 1416 let durationMs = Int(durationNs / 1_000_000) 1368 1417 continuation.yield((exitCode: exitCode, durationMs: durationMs)) 1369 1418 continuation.finish() 1419 + } 1420 + 1421 + if let tabId = tabId(containing: surfaceId) { 1422 + noteCommandFinishedForCommandDetection( 1423 + durationNs: durationNs, 1424 + surfaceId: surfaceId, 1425 + tabId: tabId 1426 + ) 1370 1427 } 1371 1428 1372 1429 // Custom command success toast. One-shot: removed regardless of outcome. ··· 1406 1463 appendNotification(title: title, body: body, surfaceId: surfaceId) 1407 1464 } 1408 1465 1466 + // MARK: - Tab Icon Auto-Detection (research / log only) 1467 + // 1468 + // Strategy: each OSC 2 title change starts a per-surface debounce 1469 + // timer. If the title is still in place after the threshold, it is a 1470 + // likely long-running command worth turning into a tab icon. Short 1471 + // titles (`ls`, `git status`, …) get overwritten by the shell's idle 1472 + // prompt long before the threshold fires and are silently dropped. 1473 + // `command_finished` lets us cross-check duration: if the command 1474 + // exceeded the threshold, log it as a confirmed long-running entry 1475 + // even when its title timer was cancelled by a precmd-driven retitle. 1476 + // 1477 + // For now this layer only emits log lines so the user can validate 1478 + // detection accuracy across their real workflows before we wire any 1479 + // icon-mapping logic on top. 1480 + 1481 + private static let commandDetectThresholdMs = 1500 1482 + 1483 + func noteTitleForCommandDetection(_ rawTitle: String, surfaceId: UUID, tabId: TerminalTabID) { 1484 + let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines) 1485 + // Empty titles tend to mean "shell reset between commands" — that's 1486 + // an end-of-candidate signal, not a new candidate. 1487 + guard !title.isEmpty else { 1488 + cancelCommandDetectionTimer(forSurfaceId: surfaceId) 1489 + return 1490 + } 1491 + // Same title again (common when shells re-emit the OSC on every 1492 + // prompt redraw) is a no-op so the existing timer keeps ticking. 1493 + if let pending = commandDetectorPendingBySurface[surfaceId], pending.title == title { 1494 + return 1495 + } 1496 + // (A) The first title to arrive after `command_finished` is the 1497 + // shell's idle prompt for this surface. Memorise it so future 1498 + // appearances skip the debounce. 1499 + if awaitingIdleTitleLearningBySurface.remove(surfaceId) != nil { 1500 + learnedIdleTitlesBySurface[surfaceId, default: []].insert(title) 1501 + } 1502 + // Substring path: TUI tools that rewrite their own title after 1503 + // launch (e.g. `claude` → `✳ Claude Code`). These bypass the 1504 + // debounce — the title *is* the brand signal, no need to wait — 1505 + // and they bypass the per-cycle `reportedRunning` guard so they 1506 + // can refine an icon already set by the initial command name. 1507 + if let immediateIcon = CommandIconMap.iconForSubstring(title) { 1508 + let summary = applyResolvedIcon(immediateIcon, surfaceId: surfaceId, tabId: tabId) 1509 + let surfaceTag = surfaceId.uuidString.prefix(8) 1510 + let tabTag = tabId.rawValue.uuidString.prefix(8) 1511 + commandDetectLogger.info( 1512 + "[immediate] tui title matched: title=\"\(title)\" " 1513 + + "tab=\(tabTag) surface=\(surfaceTag) \(summary)" 1514 + ) 1515 + return 1516 + } 1517 + // Decide whether this title should arm a debounce timer. Skip when: 1518 + // (B) it looks like an idle prompt by shape — bootstraps before 1519 + // the learner has seen `command_finished` once on this surface. 1520 + // (A) it matches a learned idle prompt for this surface. 1521 + // (D) the surface is mid-command and we already reported one — a 1522 + // long-running TUI keeps mutating its title (claude's 1523 + // spinner cycles through `✳ Claude Code` → `⠐ Claude Code` 1524 + // etc.) but it's still the same run. 1525 + let isLearnedIdle = learnedIdleTitlesBySurface[surfaceId]?.contains(title) ?? false 1526 + let shouldDebounce = 1527 + !isLikelyIdleTitleByShape(title) 1528 + && !isLearnedIdle 1529 + && reportedRunningCommandBySurface[surfaceId] == nil 1530 + guard shouldDebounce else { return } 1531 + // Park the about-to-be-replaced candidate as the "previous" title 1532 + // so a subsequent `command_finished` can name the just-finished 1533 + // command correctly even though `precmd` will have set the live 1534 + // title back to the idle prompt by then. 1535 + if let outgoing = commandDetectorPendingBySurface[surfaceId] { 1536 + lastObservedTitleBySurface[surfaceId] = outgoing.title 1537 + } 1538 + commandDetectorPendingBySurface[surfaceId]?.task.cancel() 1539 + let startedAt = ContinuousClock.now 1540 + let task = Task { [weak self] in 1541 + try? await Task.sleep(for: .milliseconds(WorktreeTerminalState.commandDetectThresholdMs)) 1542 + guard !Task.isCancelled else { return } 1543 + self?.commandDetectionTimerFired(forSurfaceId: surfaceId, tabId: tabId) 1544 + } 1545 + commandDetectorPendingBySurface[surfaceId] = CommandDetectorPending( 1546 + title: title, 1547 + startedAt: startedAt, 1548 + task: task 1549 + ) 1550 + } 1551 + 1552 + func noteCommandFinishedForCommandDetection( 1553 + durationNs: UInt64, 1554 + surfaceId: UUID, 1555 + tabId: TerminalTabID 1556 + ) { 1557 + let durationMs = Int(durationNs / 1_000_000) 1558 + // Prefer the parked previous candidate (the `preexec`-set command 1559 + // title) over the live one, which `precmd` will already have 1560 + // rewritten to the idle prompt by the time this event lands. 1561 + let parked = lastObservedTitleBySurface.removeValue(forKey: surfaceId) 1562 + let candidate = commandDetectorPendingBySurface[surfaceId] 1563 + let resolvedTitle = parked ?? candidate?.title 1564 + cancelCommandDetectionTimer(forSurfaceId: surfaceId) 1565 + // Reset the per-cycle "already reported" guard, and arm the 1566 + // idle-prompt learner so the next title (the precmd-set prompt) 1567 + // gets memorised. 1568 + reportedRunningCommandBySurface.removeValue(forKey: surfaceId) 1569 + awaitingIdleTitleLearningBySurface.insert(surfaceId) 1570 + let surfaceTag = surfaceId.uuidString.prefix(8) 1571 + let tabTag = tabId.rawValue.uuidString.prefix(8) 1572 + if durationMs >= Self.commandDetectThresholdMs { 1573 + let title = resolvedTitle ?? "<title not captured>" 1574 + commandDetectLogger.info( 1575 + "[done] long-running cmd: title=\"\(title)\" duration=\(durationMs)ms " 1576 + + "tab=\(tabTag) surface=\(surfaceTag)" 1577 + ) 1578 + } else if let resolvedTitle { 1579 + commandDetectLogger.debug( 1580 + "[skip] short cmd: title=\"\(resolvedTitle)\" duration=\(durationMs)ms " 1581 + + "tab=\(tabTag) surface=\(surfaceTag)" 1582 + ) 1583 + } 1584 + } 1585 + 1586 + /// Cancel the in-flight debounce timer and discard the transient 1587 + /// pending/parked title state. Persistent learning state (learned 1588 + /// idle prompts, per-cycle reported guard) is left intact so it can 1589 + /// keep filtering across command boundaries. Use 1590 + /// `cleanupCommandDetectorState(forSurfaceId:)` instead when the 1591 + /// surface itself is being torn down. 1592 + func cancelCommandDetectionTimer(forSurfaceId surfaceId: UUID) { 1593 + commandDetectorPendingBySurface.removeValue(forKey: surfaceId)?.task.cancel() 1594 + lastObservedTitleBySurface.removeValue(forKey: surfaceId) 1595 + } 1596 + 1597 + /// Drop every detector slot keyed by this surface. Called when a 1598 + /// surface is closed or its parent tab is torn down so we don't 1599 + /// retain learned-idle sets / pending tasks for ids that will never 1600 + /// emit again. 1601 + func cleanupCommandDetectorState(forSurfaceId surfaceId: UUID) { 1602 + cancelCommandDetectionTimer(forSurfaceId: surfaceId) 1603 + learnedIdleTitlesBySurface.removeValue(forKey: surfaceId) 1604 + awaitingIdleTitleLearningBySurface.remove(surfaceId) 1605 + reportedRunningCommandBySurface.removeValue(forKey: surfaceId) 1606 + } 1607 + 1608 + /// Heuristic shape-only detection for shell idle prompts. Used as 1609 + /// the bootstrap filter so the very first time a surface goes idle 1610 + /// — before the learner has anything to match against — we can 1611 + /// still skip the false-positive `[live]` report. Two patterns: 1612 + /// 1. `user@host[:path]` — contains `@` plus `:` or `/`, no spaces. 1613 + /// 2. Pure path — starts with `~`, `/`, or `…`, no spaces. 1614 + /// Real commands typically contain a space (program + args) or a 1615 + /// short single token (`ls`, `claude`, `vim`) that doesn't match 1616 + /// either shape, so the false-negative risk is small. 1617 + private func isLikelyIdleTitleByShape(_ title: String) -> Bool { 1618 + guard !title.contains(" ") else { return false } 1619 + if title.contains("@"), title.contains(":") || title.contains("/") { 1620 + return true 1621 + } 1622 + if title.hasPrefix("~") || title.hasPrefix("/") || title.hasPrefix("…") { 1623 + return true 1624 + } 1625 + return false 1626 + } 1627 + 1628 + private func commandDetectionTimerFired(forSurfaceId surfaceId: UUID, tabId: TerminalTabID) { 1629 + guard let pending = commandDetectorPendingBySurface[surfaceId] else { return } 1630 + let parts = (ContinuousClock.now - pending.startedAt).components 1631 + let elapsedMs = Int(parts.seconds * 1000 + parts.attoseconds / 1_000_000_000_000_000) 1632 + // Mark this command as already reported so subsequent title 1633 + // mutations within the same run (claude's spinner, vim's mode 1634 + // line, …) don't fire additional `[live]` entries until 1635 + // `command_finished` clears the guard. 1636 + reportedRunningCommandBySurface[surfaceId] = pending.title 1637 + let iconSummary = applyDetectedIcon(forCommand: pending.title, surfaceId: surfaceId, tabId: tabId) 1638 + let surfaceTag = surfaceId.uuidString.prefix(8) 1639 + let tabTag = tabId.rawValue.uuidString.prefix(8) 1640 + commandDetectLogger.info( 1641 + "[live] running cmd detected: title=\"\(pending.title)\" elapsed=\(elapsedMs)ms " 1642 + + "tab=\(tabTag) surface=\(surfaceTag) \(iconSummary)" 1643 + ) 1644 + } 1645 + 1646 + /// Side-effect of `[live]` detection: apply the icon picked by 1647 + /// first-token mapping. Selection-2 semantics — the icon stays 1648 + /// after the command exits, so the tab carries a long-lived "what 1649 + /// is this for" hint. Returns a short tag string so the log line 1650 + /// can explain *why* (or why not) the icon changed. 1651 + private func applyDetectedIcon( 1652 + forCommand title: String, 1653 + surfaceId: UUID, 1654 + tabId: TerminalTabID 1655 + ) -> String { 1656 + guard let icon = CommandIconMap.iconForFirstToken(title) else { 1657 + return "icon=<no-mapping>" 1658 + } 1659 + return applyResolvedIcon(icon, surfaceId: surfaceId, tabId: tabId) 1660 + } 1661 + 1662 + /// Apply an already-resolved icon to the tab. Shared between the 1663 + /// debounce-driven `[live]` path and the substring-driven 1664 + /// `[immediate]` path so focus / lock / unchanged checks stay in 1665 + /// one place. 1666 + private func applyResolvedIcon( 1667 + _ icon: TabIconSource, 1668 + surfaceId: UUID, 1669 + tabId: TerminalTabID 1670 + ) -> String { 1671 + // Per-tab UI is single-headed: only the focused surface in a 1672 + // multi-split tab gets to drive its tab's icon. Stops a 1673 + // background split's command from silently overriding what the 1674 + // user is currently looking at. 1675 + guard focusedSurfaceIdByTab[tabId] == surfaceId else { 1676 + return "icon=<not-focused>" 1677 + } 1678 + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { 1679 + return "icon=<tab-missing>" 1680 + } 1681 + if tab.isIconLocked { 1682 + return "icon=<user-locked>" 1683 + } 1684 + // `tab.icon` storage is the SF Symbol string; the asset variant 1685 + // (when set) is the real branding, but rendering hasn't been 1686 + // wired yet so we still write the symbol. See `TabIconSource` 1687 + // docs for the three-step recipe to enable asset rendering. 1688 + let symbol = icon.systemSymbol 1689 + if tab.icon == symbol { 1690 + return iconLogTag(icon: icon, suffix: "(unchanged)") 1691 + } 1692 + tabManager.updateIcon(tabId, icon: symbol) 1693 + return iconLogTag(icon: icon, suffix: nil) 1694 + } 1695 + 1696 + private func iconLogTag(icon: TabIconSource, suffix: String?) -> String { 1697 + var tag = "icon=sf:\(icon.systemSymbol)" 1698 + if let asset = icon.assetName { 1699 + tag += " (asset:\(asset))" 1700 + } 1701 + if let suffix { 1702 + tag += " \(suffix)" 1703 + } 1704 + return tag 1705 + } 1706 + 1409 1707 static func formatDuration(_ seconds: Int) -> String { 1410 1708 if seconds < 60 { 1411 1709 return "\(seconds)s" ··· 1427 1725 surfaces.removeValue(forKey: surface.id) 1428 1726 autoCloseSurfaceIds.remove(surface.id) 1429 1727 pendingCustomCommands.removeValue(forKey: surface.id) 1728 + cleanupCommandDetectorState(forSurfaceId: surface.id) 1430 1729 } 1431 1730 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1432 1731 tabIsRunningById.removeValue(forKey: tabId) ··· 1570 1869 surfaces.removeValue(forKey: view.id) 1571 1870 autoCloseSurfaceIds.remove(view.id) 1572 1871 pendingCustomCommands.removeValue(forKey: view.id) 1872 + cleanupCommandDetectorState(forSurfaceId: view.id) 1573 1873 return 1574 1874 } 1575 1875 guard let node = tree.find(id: view.id) else { ··· 1577 1877 surfaces.removeValue(forKey: view.id) 1578 1878 autoCloseSurfaceIds.remove(view.id) 1579 1879 pendingCustomCommands.removeValue(forKey: view.id) 1880 + cleanupCommandDetectorState(forSurfaceId: view.id) 1580 1881 return 1581 1882 } 1582 1883 let nextSurface = ··· 1588 1889 surfaces.removeValue(forKey: view.id) 1589 1890 autoCloseSurfaceIds.remove(view.id) 1590 1891 pendingCustomCommands.removeValue(forKey: view.id) 1892 + cleanupCommandDetectorState(forSurfaceId: view.id) 1591 1893 if newTree.isEmpty { 1592 1894 trees.removeValue(forKey: tabId) 1593 1895 focusedSurfaceIdByTab.removeValue(forKey: tabId)