···11+# Command Icons
22+33+Brand artwork used by the auto-detected tab icon
44+(`CommandIconMap` → `TabIconImage`). All SVGs ship as monochrome
55+templates (`template-rendering-intent: "template"` +
66+`preserves-vector-representation: true`) so they tint with the
77+surrounding `foregroundStyle` and adapt to dark / light appearance
88+without per-mode variants.
99+1010+## Sources
1111+1212+| Source | License | Imagesets |
1313+| ------ | ------- | --------- |
1414+| [Simple Icons](https://simpleicons.org/) | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | AWS, Azure, Bun, Curl, Deno, Docker, Git, GitHub, Go, GoogleCloud, Gradle, Homebrew, Kubernetes, MySQL, Neovim, Node, Npm, Pnpm, Podman, PostgreSQL, Python, Rust, SQLite, Swift, Terraform, Tmux, TypeScript, Vim, VSCode, Xcode, Yarn, Gemini |
1515+| [Lobe Icons](https://github.com/lobehub/lobe-icons) | [MIT](https://github.com/lobehub/lobe-icons/blob/master/LICENSE) | Amp, ClaudeCode, Codex, GitHubCopilot, Kimi, OpenCode |
1616+1717+`ClaudeCode` is sourced from the Lobe Icons `claude.svg` mark and
1818+re-authored as a single `fill-rule="evenodd"` path so the `>_` glyph
1919+renders as a native cutout under SwiftUI template tinting (the
2020+upstream two-path version relies on multi-colour `fill` that
2121+`Image(_:)` can't reproduce).
2222+2323+## Trademarks
2424+2525+The image files are released under permissive licenses, but the
2626+**marks themselves remain the trademarks of their respective
2727+holders**. Inclusion here is for tool integration only — surfacing a
2828+brand alongside the matching CLI is a long-standing convention in
2929+terminal apps (iTerm2, Warp, Wezterm, …) and not an endorsement.
3030+Remove or replace any entry whose holder objects.
3131+3232+## Adding a new entry
3333+3434+1. Drop a single-colour SVG (use `currentColor` or a bare path) into
3535+ `<Name>.imageset/`.
3636+2. Add a `Contents.json` mirroring an existing imageset
3737+ (`preserves-vector-representation: true` +
3838+ `template-rendering-intent: "template"`).
3939+3. Reference the asset in `CommandIconMap`:
4040+ `TabIconSource(systemSymbol: "<sf-fallback>", assetName: "<Name>")`.
4141+4. (Optional) Verify how it looks via
4242+ **Debug → Icon Catalog** in a DEBUG build.
···11+import AppKit
22+import ComposableArchitecture
33+import SwiftUI
44+55+#if DEBUG
66+77+ /// Manages the singleton Debug Window. Lifecycle mirrors
88+ /// `SettingsWindowManager`: cache the `NSWindow`, deminiaturise +
99+ /// front it on subsequent shows, never release on close so opening
1010+ /// is cheap. Configured once during app bootstrap with the root
1111+ /// store so the window can mirror the user's appearance setting.
1212+ @MainActor
1313+ final class DebugWindowManager {
1414+ static let shared = DebugWindowManager()
1515+1616+ private var window: NSWindow?
1717+ private var store: StoreOf<AppFeature>?
1818+1919+ private init() {}
2020+2121+ func configure(store: StoreOf<AppFeature>) {
2222+ self.store = store
2323+ }
2424+2525+ func show() {
2626+ if let existing = window {
2727+ if existing.isMiniaturized {
2828+ existing.deminiaturize(nil)
2929+ }
3030+ existing.makeKeyAndOrderFront(nil)
3131+ return
3232+ }
3333+3434+ guard let store else { return }
3535+ let host = NSHostingController(rootView: DebugView(store: store))
3636+ let new = NSWindow(contentViewController: host)
3737+ new.title = "Prowl Debug"
3838+ new.identifier = NSUserInterfaceItemIdentifier("debug")
3939+ new.styleMask = [.titled, .closable, .miniaturizable, .resizable]
4040+ new.tabbingMode = .disallowed
4141+ new.toolbarStyle = .unified
4242+ new.toolbar = NSToolbar(identifier: "DebugToolbar")
4343+ if #unavailable(macOS 15.0) {
4444+ new.toolbar?.showsBaselineSeparator = false
4545+ }
4646+ new.isReleasedWhenClosed = false
4747+ new.setContentSize(NSSize(width: 800, height: 600))
4848+ new.minSize = NSSize(width: 700, height: 500)
4949+ new.center()
5050+ new.makeKeyAndOrderFront(nil)
5151+ window = new
5252+ }
5353+ }
5454+5555+#endif
+16
supacode/Features/Debug/Views/DebugSection.swift
···11+import Foundation
22+33+#if DEBUG
44+55+ /// Sidebar entries for the Debug Window. Add a case here, an entry
66+ /// in `DebugView`'s sidebar list, and a switch arm in the detail
77+ /// area to register a new debug surface.
88+ enum DebugSection: Hashable {
99+ /// Catalogue of every `CommandIconMap` entry alongside its rendered
1010+ /// icon. Lets us eyeball the auto-detected tab-icon set after
1111+ /// adding new branded artwork or sanity-checking that an asset
1212+ /// actually paints in the SwiftUI runtime.
1313+ case iconCatalog
1414+ }
1515+1616+#endif
+47
supacode/Features/Debug/Views/DebugView.swift
···11+import ComposableArchitecture
22+import SwiftUI
33+44+#if DEBUG
55+66+ /// Root of the Debug Window. NavigationSplitView with a sidebar so
77+ /// future debug surfaces (detector state, analytics events,
88+ /// ghostty internals…) can be added by extending `DebugSection`
99+ /// and the sidebar list / detail switch below. The store is held
1010+ /// only to mirror the app-wide appearance setting on this window;
1111+ /// individual debug surfaces don't have to thread it.
1212+ struct DebugView: View {
1313+ let store: StoreOf<AppFeature>
1414+ @State private var selection: DebugSection = .iconCatalog
1515+1616+ var body: some View {
1717+ NavigationSplitView(columnVisibility: .constant(.all)) {
1818+ List(selection: $selection) {
1919+ Label("Icon Catalog", systemImage: "square.grid.2x2")
2020+ .tag(DebugSection.iconCatalog)
2121+ }
2222+ .listStyle(.sidebar)
2323+ .frame(minWidth: 200, maxHeight: .infinity)
2424+ .navigationSplitViewColumnWidth(200)
2525+ } detail: {
2626+ Group {
2727+ switch selection {
2828+ case .iconCatalog:
2929+ IconCatalogView()
3030+ .navigationTitle("Icon Catalog")
3131+ .navigationSubtitle("CommandIconMap entries (DEBUG)")
3232+ }
3333+ }
3434+ .frame(maxWidth: .infinity, maxHeight: .infinity)
3535+ }
3636+ .navigationSplitViewStyle(.balanced)
3737+ .frame(minWidth: 700, minHeight: 500)
3838+ .background {
3939+ // Standalone NSWindow doesn't pick up `.preferredColorScheme`
4040+ // (only WindowGroup scenes do), so route through the same
4141+ // bridge SettingsView uses to honour the user's appearance.
4242+ WindowAppearanceSetter(colorScheme: store.settings.appearanceMode.colorScheme)
4343+ }
4444+ }
4545+ }
4646+4747+#endif
···350350 }
351351 .accessibilityHidden(true)
352352 } else {
353353- Image(systemName: tab.icon ?? ShelfMetrics.defaultTabIcon)
354354- .imageScale(.medium)
355355- .foregroundStyle(foregroundTint)
356356- // Dim tabs without a hotkey when ⌘ is held, so the "this slot
357357- // can't be jumped to via Cmd+N" affordance is legible without
358358- // shifting any layout.
359359- .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil ? 0.45 : 1)
360360- .accessibilityHidden(true)
353353+ TabIconImage(
354354+ rawName: tab.icon ?? ShelfMetrics.defaultTabIcon,
355355+ pointSize: ShelfMetrics.tabIconPointSize
356356+ )
357357+ .foregroundStyle(foregroundTint)
358358+ // Dim tabs without a hotkey when ⌘ is held, so the "this slot
359359+ // can't be jumped to via Cmd+N" affordance is legible without
360360+ // shifting any layout.
361361+ .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil ? 0.45 : 1)
362362+ .accessibilityHidden(true)
361363 }
362364 }
363365···418420 static let headerMaxLength: CGFloat = 160
419421 /// Fallback icon when a tab has no custom icon set.
420422 static let defaultTabIcon: String = "terminal"
423423+ /// Point size used by `TabIconImage` for both the SF Symbol
424424+ /// (`.font(.system(size:))`) and the asset (`.frame`) branches so
425425+ /// branded artwork visually matches the SF-Symbol fallback.
426426+ static let tabIconPointSize: CGFloat = 18
421427}
···11+import Foundation
22+33+/// Specifies the artwork to use for a tab icon. `systemSymbol` is an
44+/// always-available SF Symbol fallback; `assetName` is an optional,
55+/// more specific PNG/SVG shipped in the asset catalog (used for
66+/// branded CLI tools like docker/git/claude where stock SF Symbols
77+/// don't read well).
88+///
99+/// Renderers prefer `assetName` when present, falling back to
1010+/// `systemSymbol` when the asset is missing or asset rendering isn't
1111+/// yet wired into a particular call site.
1212+struct TabIconSource: Equatable, Hashable, Sendable {
1313+ /// SF Symbol drawn via `Image(systemName:)`.
1414+ let systemSymbol: String
1515+ /// Asset catalog entry. When set, renderers paint `Image(_:)` for
1616+ /// this name and ignore `systemSymbol`; when missing, they fall
1717+ /// back to the SF Symbol.
1818+ let assetName: String?
1919+2020+ init(systemSymbol: String, assetName: String? = nil) {
2121+ self.systemSymbol = systemSymbol
2222+ self.assetName = assetName
2323+ }
2424+2525+ /// Serialised form stored in `TerminalTabItem.icon`. SF Symbols are
2626+ /// stored bare (so existing `tab.icon = "terminal"` keeps working
2727+ /// for the user-icon-picker path); assets carry a marker so the
2828+ /// renderer can switch APIs.
2929+ var storageString: String {
3030+ if let assetName {
3131+ return ResolvedTabIcon.assetMarker + assetName
3232+ }
3333+ return systemSymbol
3434+ }
3535+}
3636+3737+/// What `TerminalTabItem.icon` resolves to once parsed by the
3838+/// renderer. Built from a stored string via `parse(_:)` — the inverse
3939+/// of `TabIconSource.storageString`. Lives next to `TabIconSource`
4040+/// because the two share the marker convention.
4141+enum ResolvedTabIcon: Equatable, Hashable, Sendable {
4242+ case systemSymbol(String)
4343+ case asset(name: String)
4444+4545+ static let assetMarker = "@asset:"
4646+4747+ static func parse(_ raw: String) -> ResolvedTabIcon {
4848+ if raw.hasPrefix(assetMarker) {
4949+ return .asset(name: String(raw.dropFirst(assetMarker.count)))
5050+ }
5151+ return .systemSymbol(raw)
5252+ }
5353+}
···5050 /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success
5151 /// toast when the command exits with code 0. One-shot: removed on the first finish event.
5252 private var pendingCustomCommands: [UUID: String] = [:]
5353+ /// Per-surface set of titles known to be the shell's idle prompt
5454+ /// (the title `precmd` restores between commands). Populated by
5555+ /// observing the first title that arrives after each
5656+ /// `command_finished` — reliably the precmd-set prompt. Subsequent
5757+ /// occurrences are skipped so they can't clobber the icon set by a
5858+ /// real command.
5959+ private var learnedIdleTitlesBySurface: [UUID: Set<String>] = [:]
6060+ /// Surfaces whose next title-change should be added to
6161+ /// `learnedIdleTitlesBySurface`. Armed by `command_finished`,
6262+ /// consumed by the next title arrival.
6363+ private var awaitingIdleTitleLearningBySurface: Set<UUID> = []
5364 var hasUnseenNotification: Bool {
5465 notifications.contains { !$0.isRead }
5566 }
···525536 } catch {
526537 newSurface.closeSurface()
527538 surfaces.removeValue(forKey: newSurface.id)
539539+ cleanupCommandDetectorState(forSurfaceId: newSurface.id)
528540 return nil
529541 }
530542 }
···593605 } catch {
594606 newSurface.closeSurface()
595607 surfaces.removeValue(forKey: newSurface.id)
608608+ cleanupCommandDetectorState(forSurfaceId: newSurface.id)
596609597610 return false
598611 }
···9871000 if self.focusedSurfaceIdByTab[tabId] == view.id {
9881001 self.tabManager.updateTitle(tabId, title: title)
9891002 }
10031003+ self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId)
9901004 }
9911005 view.bridge.onSplitAction = { [weak self, weak view] action in
9921006 guard let self, let view else { return false }
···13691383 continuation.finish()
13701384 }
1371138513861386+ noteCommandFinishedForCommandDetection(surfaceId: surfaceId)
13871387+13721388 // Custom command success toast. One-shot: removed regardless of outcome.
13731389 if let commandName = pendingCustomCommands.removeValue(forKey: surfaceId), exitCode == 0 {
13741390 let durationMs = Int(durationNs / 1_000_000)
···14061422 appendNotification(title: title, body: body, surfaceId: surfaceId)
14071423 }
1408142414251425+ // MARK: - Tab Icon Auto-Detection
14261426+ //
14271427+ // Strategy: each OSC 2 title change is matched against
14281428+ // `CommandIconMap` (substring rules first, then first-token). A hit
14291429+ // applies the icon immediately — no debounce. Rationale: the
14301430+ // mapping is a curated allow-list, so a hit is by definition a
14311431+ // command we're happy to brand the tab with; a miss leaves the
14321432+ // existing icon untouched (selection-2 semantics).
14331433+ //
14341434+ // Idle-prompt suppression keeps the lookup focused on real
14351435+ // commands: the first title after each `command_finished` is the
14361436+ // shell's `precmd`-set prompt, and gets memorised into a learned-
14371437+ // idle set so we never reach the mapping with a `user@host`-style
14381438+ // string. Shape heuristics (`isLikelyIdleTitleByShape`) cover the
14391439+ // bootstrap window before the learner has seen anything.
14401440+ //
14411441+ // The mapping-hit-equals-apply rule also unblocks short-lived
14421442+ // commands (`git status`, `cd foo`) and TUIs that immediately
14431443+ // overwrite their preexec title (`codex` → repo name) — both used
14441444+ // to slip past a debounce-based detector.
14451445+14461446+ func noteTitleForCommandDetection(_ rawTitle: String, surfaceId: UUID, tabId: TerminalTabID) {
14471447+ let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
14481448+ guard !title.isEmpty else { return }
14491449+ // Learn this surface's idle prompt: the first title after
14501450+ // `command_finished` is reliably the precmd-set one.
14511451+ if awaitingIdleTitleLearningBySurface.remove(surfaceId) != nil {
14521452+ learnedIdleTitlesBySurface[surfaceId, default: []].insert(title)
14531453+ }
14541454+ // Drop idle prompts so they can't reach the mapping lookup.
14551455+ if Self.isLikelyIdleTitleByShape(title) { return }
14561456+ if learnedIdleTitlesBySurface[surfaceId]?.contains(title) == true { return }
14571457+ guard let icon = CommandIconMap.iconForFirstToken(title) else { return }
14581458+ applyResolvedIcon(icon, surfaceId: surfaceId, tabId: tabId)
14591459+ }
14601460+14611461+ func noteCommandFinishedForCommandDetection(surfaceId: UUID) {
14621462+ // Arm the idle-prompt learner: the next title arrival is the
14631463+ // precmd-set prompt and should join the learned-idle set.
14641464+ awaitingIdleTitleLearningBySurface.insert(surfaceId)
14651465+ }
14661466+14671467+ /// Drop the per-surface detector state. Called when a surface is
14681468+ /// closed or its parent tab is torn down so we don't retain
14691469+ /// learned-idle sets keyed by ids that will never emit again.
14701470+ func cleanupCommandDetectorState(forSurfaceId surfaceId: UUID) {
14711471+ learnedIdleTitlesBySurface.removeValue(forKey: surfaceId)
14721472+ awaitingIdleTitleLearningBySurface.remove(surfaceId)
14731473+ }
14741474+14751475+ /// Heuristic shape-only detection for shell idle prompts. The
14761476+ /// bootstrap filter — before `awaitingIdleTitleLearning` has caught
14771477+ /// the precmd-set prompt at least once on this surface — for two
14781478+ /// common forms:
14791479+ /// 1. `user@host[:path]` — contains `@` plus `:` or `/`, no spaces.
14801480+ /// 2. Pure path — starts with `~`, `/`, or `…`, no spaces.
14811481+ /// Real commands typically contain a space (program + args) or a
14821482+ /// short single token (`ls`, `claude`, `vim`) that doesn't match
14831483+ /// either shape, so the false-negative risk is small.
14841484+ ///
14851485+ /// Exposed (`internal static`) for direct unit testing — does not
14861486+ /// touch instance state.
14871487+ static func isLikelyIdleTitleByShape(_ title: String) -> Bool {
14881488+ guard !title.contains(" ") else { return false }
14891489+ if title.contains("@"), title.contains(":") || title.contains("/") {
14901490+ return true
14911491+ }
14921492+ if title.hasPrefix("~") || title.hasPrefix("/") || title.hasPrefix("…") {
14931493+ return true
14941494+ }
14951495+ return false
14961496+ }
14971497+14981498+ /// 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`.
15021502+ private func applyResolvedIcon(
15031503+ _ icon: TabIconSource,
15041504+ surfaceId: UUID,
15051505+ tabId: TerminalTabID
15061506+ ) {
15071507+ // Per-tab UI is single-headed: only the focused surface in a
15081508+ // multi-split tab gets to drive its tab's icon. Stops a
15091509+ // background split's command from silently overriding what the
15101510+ // user is currently looking at.
15111511+ guard focusedSurfaceIdByTab[tabId] == surfaceId else { return }
15121512+ guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return }
15131513+ guard !tab.isIconLocked else { return }
15141514+ let serialised = icon.storageString
15151515+ guard tab.icon != serialised else { return }
15161516+ tabManager.updateIcon(tabId, icon: serialised)
15171517+ }
15181518+14091519 static func formatDuration(_ seconds: Int) -> String {
14101520 if seconds < 60 {
14111521 return "\(seconds)s"
···14271537 surfaces.removeValue(forKey: surface.id)
14281538 autoCloseSurfaceIds.remove(surface.id)
14291539 pendingCustomCommands.removeValue(forKey: surface.id)
15401540+ cleanupCommandDetectorState(forSurfaceId: surface.id)
14301541 }
14311542 focusedSurfaceIdByTab.removeValue(forKey: tabId)
14321543 tabIsRunningById.removeValue(forKey: tabId)
···15701681 surfaces.removeValue(forKey: view.id)
15711682 autoCloseSurfaceIds.remove(view.id)
15721683 pendingCustomCommands.removeValue(forKey: view.id)
16841684+ cleanupCommandDetectorState(forSurfaceId: view.id)
15731685 return
15741686 }
15751687 guard let node = tree.find(id: view.id) else {
···15771689 surfaces.removeValue(forKey: view.id)
15781690 autoCloseSurfaceIds.remove(view.id)
15791691 pendingCustomCommands.removeValue(forKey: view.id)
16921692+ cleanupCommandDetectorState(forSurfaceId: view.id)
15801693 return
15811694 }
15821695 let nextSurface =
···15881701 surfaces.removeValue(forKey: view.id)
15891702 autoCloseSurfaceIds.remove(view.id)
15901703 pendingCustomCommands.removeValue(forKey: view.id)
17041704+ cleanupCommandDetectorState(forSurfaceId: view.id)
15911705 if newTree.isEmpty {
15921706 trees.removeValue(forKey: tabId)
15931707 focusedSurfaceIdByTab.removeValue(forKey: tabId)
···11+import SwiftUI
22+33+/// Renders the icon for a `TerminalTabItem`. Decodes the storage
44+/// string via `ResolvedTabIcon` and dispatches to either
55+/// `Image(systemName:)` (SF Symbol) or `Image(_:)` (asset catalog).
66+/// Both branches honour the surrounding `foregroundStyle` because
77+/// asset entries ship as template SVGs (`@asset:` marker — see
88+/// `TabIconSource.storageString` and `Assets.xcassets/CommandIcons`).
99+///
1010+/// `pointSize` is the on-screen size both branches target: the SF
1111+/// Symbol path uses `.font(.system(size:))` so the symbol scales
1212+/// with the value; the asset path uses `.resizable() + frame` for
1313+/// the same final footprint. Keeping both branches at the same
1414+/// nominal size avoids visual jumps when a tab switches between an
1515+/// SF Symbol fallback and a branded asset.
1616+struct TabIconImage: View {
1717+ let rawName: String
1818+ let pointSize: CGFloat
1919+2020+ var body: some View {
2121+ Group {
2222+ switch ResolvedTabIcon.parse(rawName) {
2323+ case .systemSymbol(let name):
2424+ Image(systemName: name)
2525+ .font(.system(size: pointSize))
2626+ case .asset(let name):
2727+ Image(name)
2828+ .resizable()
2929+ .scaledToFit()
3030+ .frame(width: pointSize, height: pointSize)
3131+ }
3232+ }
3333+ // Tab icons are decorative — `tab.title` already provides the
3434+ // accessible label for the tab. Callers that need a custom label
3535+ // can override after construction.
3636+ .accessibilityHidden(true)
3737+ }
3838+}
+94
supacodeTests/CommandIconMapTests.swift
···11+import Testing
22+33+@testable import supacode
44+55+struct CommandIconMapTests {
66+ // MARK: - First-token resolution
77+88+ @Test func resolvesExactToken() throws {
99+ let icon = try #require(CommandIconMap.iconForFirstToken("git"))
1010+ #expect(icon.systemSymbol == "arrow.triangle.branch")
1111+ #expect(icon.assetName == "Git")
1212+ }
1313+1414+ @Test func resolvesByFirstTokenWithArgs() {
1515+ // "git status" should match the `git` entry, not look up "git status".
1616+ #expect(CommandIconMap.iconForFirstToken("git status")?.assetName == "Git")
1717+ #expect(CommandIconMap.iconForFirstToken("swift build --release")?.assetName == "Swift")
1818+ #expect(CommandIconMap.iconForFirstToken("docker compose up -d")?.assetName == "Docker")
1919+ }
2020+2121+ @Test func lookupIsCaseInsensitive() {
2222+ #expect(CommandIconMap.iconForFirstToken("GIT")?.assetName == "Git")
2323+ #expect(CommandIconMap.iconForFirstToken("Docker")?.assetName == "Docker")
2424+ #expect(CommandIconMap.iconForFirstToken("CLAUDE")?.assetName == "ClaudeCode")
2525+ }
2626+2727+ @Test func returnsNilForUnknownToken() {
2828+ #expect(CommandIconMap.iconForFirstToken("never-heard-of-this-cli") == nil)
2929+ #expect(CommandIconMap.iconForFirstToken("xyzzy") == nil)
3030+ }
3131+3232+ @Test func returnsNilForEmptyTitle() {
3333+ #expect(CommandIconMap.iconForFirstToken("") == nil)
3434+ }
3535+3636+ @Test func handlesLeadingWhitespace() {
3737+ // `split(omittingEmpty:)` skips the leading space so the first
3838+ // real token still resolves.
3939+ #expect(CommandIconMap.iconForFirstToken(" git status")?.assetName == "Git")
4040+ }
4141+4242+ // MARK: - Aliases reuse the right asset
4343+4444+ @Test func packageManagerAliasesShareAssets() {
4545+ // Runners share the icon of their parent package manager.
4646+ #expect(CommandIconMap.iconForFirstToken("npx")?.assetName == "Npm")
4747+ #expect(CommandIconMap.iconForFirstToken("bunx")?.assetName == "Bun")
4848+ #expect(CommandIconMap.iconForFirstToken("pip")?.assetName == "Python")
4949+ #expect(CommandIconMap.iconForFirstToken("pip3")?.assetName == "Python")
5050+ }
5151+5252+ @Test func tuiFrontendsShareAssets() {
5353+ // lazygit/lazydocker are TUI frontends — share the icon.
5454+ #expect(CommandIconMap.iconForFirstToken("lazygit")?.assetName == "Git")
5555+ #expect(CommandIconMap.iconForFirstToken("lazydocker")?.assetName == "Docker")
5656+ }
5757+5858+ @Test func pythonAliasMapsToPython() {
5959+ #expect(CommandIconMap.iconForFirstToken("python")?.assetName == "Python")
6060+ #expect(CommandIconMap.iconForFirstToken("python3")?.assetName == "Python")
6161+ }
6262+6363+ // MARK: - Coding agents
6464+6565+ @Test func codingAgentsResolved() {
6666+ // Sample of the coding-agent set — they all share the sparkle SF
6767+ // Symbol fallback, asset names match the imageset folders.
6868+ #expect(CommandIconMap.iconForFirstToken("claude")?.assetName == "ClaudeCode")
6969+ #expect(CommandIconMap.iconForFirstToken("codex")?.assetName == "Codex")
7070+ #expect(CommandIconMap.iconForFirstToken("gemini")?.assetName == "Gemini")
7171+ #expect(CommandIconMap.iconForFirstToken("copilot")?.assetName == "GitHubCopilot")
7272+ // aider/droid have no brand asset — sparkle fallback only.
7373+ #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle")
7474+ #expect(CommandIconMap.iconForFirstToken("aider")?.assetName == nil)
7575+ #expect(CommandIconMap.iconForFirstToken("droid")?.systemSymbol == "sparkle")
7676+ }
7777+7878+ // MARK: - Debug catalog
7979+8080+ @Test func debugAllEntriesIsSorted() {
8181+ let tokens = CommandIconMap.debugAllEntries.map(\.token)
8282+ #expect(tokens == tokens.sorted())
8383+ }
8484+8585+ @Test func debugAllEntriesCoversWellKnownTokens() {
8686+ let tokens = Set(CommandIconMap.debugAllEntries.map(\.token))
8787+ // Spot-check that the debug surface actually exposes the tokens
8888+ // a user is most likely to hunt for.
8989+ let mustHave: Set<String> = [
9090+ "git", "docker", "claude", "vim", "ssh", "npm", "swift",
9191+ ]
9292+ #expect(mustHave.isSubset(of: tokens))
9393+ }
9494+}
···11+import Testing
22+33+@testable import supacode
44+55+/// Pure-shape detection of the shell's idle prompt
66+/// (`isLikelyIdleTitleByShape`). The bootstrap filter that runs
77+/// before the per-surface learner has memorised the prompt at least
88+/// once.
99+struct IconDetectorIdleHeuristicTests {
1010+ // MARK: - Idle prompt shapes
1111+1212+ @Test func detectsUserAtHostWithColonPath() {
1313+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("onevcat@Mac:~/Sync/github/YiTong"))
1414+ }
1515+1616+ @Test func detectsUserAtHostWithSlashOnly() {
1717+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("onevcat@Mac:/usr/local/etc"))
1818+ }
1919+2020+ @Test func detectsTildePath() {
2121+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("~/Sync/github"))
2222+ }
2323+2424+ @Test func detectsAbsolutePath() {
2525+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("/usr/local/bin"))
2626+ }
2727+2828+ @Test func detectsTruncatedPathWithEllipsis() {
2929+ // zsh's "compact path" renders as `…/Sync/github/YiTong`.
3030+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("…/Sync/github/YiTong"))
3131+ }
3232+3333+ // MARK: - Real commands should not be flagged
3434+3535+ @Test func commandWithSpaceIsNotIdle() {
3636+ // Anything with a space is treated as a real command (program +
3737+ // args) — this is the primary discriminator.
3838+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("git status"))
3939+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("vim file.swift"))
4040+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("docker compose up"))
4141+ }
4242+4343+ @Test func barCommandTokenIsNotIdle() {
4444+ // Single-token commands without `@`, `~`, `/`, or `…` are real
4545+ // commands.
4646+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("claude"))
4747+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("vim"))
4848+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("npm"))
4949+ }
5050+5151+ @Test func tuiTitleIsNotIdle() {
5252+ // TUI tools that rewrite their own title (claude → spinner glyphs)
5353+ // contain spaces and should still be classified as commands.
5454+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("✳ Claude Code"))
5555+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("⠐ Claude Code"))
5656+ }
5757+5858+ @Test func emptyTitleIsNotIdle() {
5959+ // Empty handled by the caller; the heuristic itself returns false
6060+ // (no `@`, no leading `~`/`/`/`…`).
6161+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape(""))
6262+ }
6363+6464+ // MARK: - Edge cases
6565+6666+ @Test func atSymbolWithoutPathSeparatorIsNotIdle() {
6767+ // `git@github.com` would be a typical SSH remote, not an idle
6868+ // prompt. Without `:` or `/` it's not classified as idle.
6969+ #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("git@github.com"))
7070+ }
7171+7272+ @Test func absolutePathExecutableIsClassifiedAsIdle() {
7373+ // Documented limitation: `/usr/bin/python3` (rare invocation
7474+ // form) shape-matches as a "path" prompt and gets skipped. The
7575+ // tradeoff is fine — typical use is `python3`, not the absolute
7676+ // path.
7777+ #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("/usr/bin/python3"))
7878+ }
7979+}
+70
supacodeTests/TabIconSourceTests.swift
···11+import Testing
22+33+@testable import supacode
44+55+struct TabIconSourceTests {
66+ // MARK: - storageString encoding
77+88+ @Test func sfSymbolOnlySerialisesBare() {
99+ // SF-Symbol-only entries serialise as the bare symbol name so
1010+ // the existing IconPicker storage path keeps working unchanged.
1111+ let icon = TabIconSource(systemSymbol: "terminal")
1212+ #expect(icon.storageString == "terminal")
1313+ }
1414+1515+ @Test func assetEntrySerialisesWithMarker() {
1616+ // Asset-bearing entries get the `@asset:` prefix the renderer
1717+ // parses via `ResolvedTabIcon`.
1818+ let icon = TabIconSource(systemSymbol: "shippingbox", assetName: "Docker")
1919+ #expect(icon.storageString == "@asset:Docker")
2020+ }
2121+2222+ @Test func assetEntryOmitsSystemSymbolFromStorage() {
2323+ // `systemSymbol` stays only as a fallback for renderers that
2424+ // can't resolve the asset; storage carries the asset.
2525+ let icon = TabIconSource(systemSymbol: "sparkle", assetName: "ClaudeCode")
2626+ #expect(icon.storageString == "@asset:ClaudeCode")
2727+ #expect(!icon.storageString.contains("sparkle"))
2828+ }
2929+3030+ // MARK: - ResolvedTabIcon parsing
3131+3232+ @Test func parsesBareStringAsSystemSymbol() {
3333+ let resolved = ResolvedTabIcon.parse("terminal")
3434+ #expect(resolved == .systemSymbol("terminal"))
3535+ }
3636+3737+ @Test func parsesAssetMarker() {
3838+ let resolved = ResolvedTabIcon.parse("@asset:Docker")
3939+ #expect(resolved == .asset(name: "Docker"))
4040+ }
4141+4242+ @Test func parsesAssetMarkerWithSpaces() {
4343+ // Asset names can contain spaces (e.g. "Visual Studio Code"), so
4444+ // the parser must keep everything after the marker prefix intact.
4545+ let resolved = ResolvedTabIcon.parse("@asset:Visual Studio Code")
4646+ #expect(resolved == .asset(name: "Visual Studio Code"))
4747+ }
4848+4949+ @Test func sfSymbolStringWithColonStaysSymbol() {
5050+ // Edge: SF Symbol names never start with `@asset:`, so a literal
5151+ // colon-bearing symbol (none today, but defensive) doesn't trip
5252+ // the parser.
5353+ let resolved = ResolvedTabIcon.parse("foo:bar")
5454+ #expect(resolved == .systemSymbol("foo:bar"))
5555+ }
5656+5757+ // MARK: - Round-trip
5858+5959+ @Test func sfSymbolRoundTrip() {
6060+ let source = TabIconSource(systemSymbol: "hammer")
6161+ let parsed = ResolvedTabIcon.parse(source.storageString)
6262+ #expect(parsed == .systemSymbol("hammer"))
6363+ }
6464+6565+ @Test func assetRoundTrip() {
6666+ let source = TabIconSource(systemSymbol: "shippingbox", assetName: "Npm")
6767+ let parsed = ResolvedTabIcon.parse(source.storageString)
6868+ #expect(parsed == .asset(name: "Npm"))
6969+ }
7070+}