diff --git a/supacode/Assets.xcassets/CommandIcons/README.md b/supacode/Assets.xcassets/CommandIcons/README.md new file mode 100644 index 00000000..b59a6aea --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/README.md @@ -0,0 +1,42 @@ +# Command Icons + +Brand artwork used by the auto-detected tab icon +(`CommandIconMap` → `TabIconImage`). All SVGs ship as monochrome +templates (`template-rendering-intent: "template"` + +`preserves-vector-representation: true`) so they tint with the +surrounding `foregroundStyle` and adapt to dark / light appearance +without per-mode variants. + +## Sources + +| Source | License | Imagesets | +| ------ | ------- | --------- | +| [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 | +| [Lobe Icons](https://github.com/lobehub/lobe-icons) | [MIT](https://github.com/lobehub/lobe-icons/blob/master/LICENSE) | Amp, ClaudeCode, Codex, GitHubCopilot, Kimi, OpenCode | + +`ClaudeCode` is sourced from the Lobe Icons `claude.svg` mark and +re-authored as a single `fill-rule="evenodd"` path so the `>_` glyph +renders as a native cutout under SwiftUI template tinting (the +upstream two-path version relies on multi-colour `fill` that +`Image(_:)` can't reproduce). + +## Trademarks + +The image files are released under permissive licenses, but the +**marks themselves remain the trademarks of their respective +holders**. Inclusion here is for tool integration only — surfacing a +brand alongside the matching CLI is a long-standing convention in +terminal apps (iTerm2, Warp, Wezterm, …) and not an endorsement. +Remove or replace any entry whose holder objects. + +## Adding a new entry + +1. Drop a single-colour SVG (use `currentColor` or a bare path) into + `.imageset/`. +2. Add a `Contents.json` mirroring an existing imageset + (`preserves-vector-representation: true` + + `template-rendering-intent: "template"`). +3. Reference the asset in `CommandIconMap`: + `TabIconSource(systemSymbol: "", assetName: "")`. +4. (Optional) Verify how it looks via + **Debug → Icon Catalog** in a DEBUG build. diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2d1629e2..b5ea90f5 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1452,7 +1452,7 @@ final class WorktreeTerminalState { learnedIdleTitlesBySurface[surfaceId, default: []].insert(title) } // Drop idle prompts so they can't reach the mapping lookup. - if isLikelyIdleTitleByShape(title) { return } + if Self.isLikelyIdleTitleByShape(title) { return } if learnedIdleTitlesBySurface[surfaceId]?.contains(title) == true { return } guard let icon = CommandIconMap.iconForFirstToken(title) else { return } applyResolvedIcon(icon, surfaceId: surfaceId, tabId: tabId) @@ -1481,7 +1481,10 @@ final class WorktreeTerminalState { /// Real commands typically contain a space (program + args) or a /// short single token (`ls`, `claude`, `vim`) that doesn't match /// either shape, so the false-negative risk is small. - private func isLikelyIdleTitleByShape(_ title: String) -> Bool { + /// + /// Exposed (`internal static`) for direct unit testing — does not + /// touch instance state. + static func isLikelyIdleTitleByShape(_ title: String) -> Bool { guard !title.contains(" ") else { return false } if title.contains("@"), title.contains(":") || title.contains("/") { return true diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift new file mode 100644 index 00000000..beac311a --- /dev/null +++ b/supacodeTests/CommandIconMapTests.swift @@ -0,0 +1,94 @@ +import Testing + +@testable import supacode + +struct CommandIconMapTests { + // MARK: - First-token resolution + + @Test func resolvesExactToken() throws { + let icon = try #require(CommandIconMap.iconForFirstToken("git")) + #expect(icon.systemSymbol == "arrow.triangle.branch") + #expect(icon.assetName == "Git") + } + + @Test func resolvesByFirstTokenWithArgs() { + // "git status" should match the `git` entry, not look up "git status". + #expect(CommandIconMap.iconForFirstToken("git status")?.assetName == "Git") + #expect(CommandIconMap.iconForFirstToken("swift build --release")?.assetName == "Swift") + #expect(CommandIconMap.iconForFirstToken("docker compose up -d")?.assetName == "Docker") + } + + @Test func lookupIsCaseInsensitive() { + #expect(CommandIconMap.iconForFirstToken("GIT")?.assetName == "Git") + #expect(CommandIconMap.iconForFirstToken("Docker")?.assetName == "Docker") + #expect(CommandIconMap.iconForFirstToken("CLAUDE")?.assetName == "ClaudeCode") + } + + @Test func returnsNilForUnknownToken() { + #expect(CommandIconMap.iconForFirstToken("never-heard-of-this-cli") == nil) + #expect(CommandIconMap.iconForFirstToken("xyzzy") == nil) + } + + @Test func returnsNilForEmptyTitle() { + #expect(CommandIconMap.iconForFirstToken("") == nil) + } + + @Test func handlesLeadingWhitespace() { + // `split(omittingEmpty:)` skips the leading space so the first + // real token still resolves. + #expect(CommandIconMap.iconForFirstToken(" git status")?.assetName == "Git") + } + + // MARK: - Aliases reuse the right asset + + @Test func packageManagerAliasesShareAssets() { + // Runners share the icon of their parent package manager. + #expect(CommandIconMap.iconForFirstToken("npx")?.assetName == "Npm") + #expect(CommandIconMap.iconForFirstToken("bunx")?.assetName == "Bun") + #expect(CommandIconMap.iconForFirstToken("pip")?.assetName == "Python") + #expect(CommandIconMap.iconForFirstToken("pip3")?.assetName == "Python") + } + + @Test func tuiFrontendsShareAssets() { + // lazygit/lazydocker are TUI frontends — share the icon. + #expect(CommandIconMap.iconForFirstToken("lazygit")?.assetName == "Git") + #expect(CommandIconMap.iconForFirstToken("lazydocker")?.assetName == "Docker") + } + + @Test func pythonAliasMapsToPython() { + #expect(CommandIconMap.iconForFirstToken("python")?.assetName == "Python") + #expect(CommandIconMap.iconForFirstToken("python3")?.assetName == "Python") + } + + // MARK: - Coding agents + + @Test func codingAgentsResolved() { + // Sample of the coding-agent set — they all share the sparkle SF + // Symbol fallback, asset names match the imageset folders. + #expect(CommandIconMap.iconForFirstToken("claude")?.assetName == "ClaudeCode") + #expect(CommandIconMap.iconForFirstToken("codex")?.assetName == "Codex") + #expect(CommandIconMap.iconForFirstToken("gemini")?.assetName == "Gemini") + #expect(CommandIconMap.iconForFirstToken("copilot")?.assetName == "GitHubCopilot") + // aider/droid have no brand asset — sparkle fallback only. + #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle") + #expect(CommandIconMap.iconForFirstToken("aider")?.assetName == nil) + #expect(CommandIconMap.iconForFirstToken("droid")?.systemSymbol == "sparkle") + } + + // MARK: - Debug catalog + + @Test func debugAllEntriesIsSorted() { + let tokens = CommandIconMap.debugAllEntries.map(\.token) + #expect(tokens == tokens.sorted()) + } + + @Test func debugAllEntriesCoversWellKnownTokens() { + let tokens = Set(CommandIconMap.debugAllEntries.map(\.token)) + // Spot-check that the debug surface actually exposes the tokens + // a user is most likely to hunt for. + let mustHave: Set = [ + "git", "docker", "claude", "vim", "ssh", "npm", "swift", + ] + #expect(mustHave.isSubset(of: tokens)) + } +} diff --git a/supacodeTests/IconDetectorIdleHeuristicTests.swift b/supacodeTests/IconDetectorIdleHeuristicTests.swift new file mode 100644 index 00000000..f03bdfb7 --- /dev/null +++ b/supacodeTests/IconDetectorIdleHeuristicTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import supacode + +/// Pure-shape detection of the shell's idle prompt +/// (`isLikelyIdleTitleByShape`). The bootstrap filter that runs +/// before the per-surface learner has memorised the prompt at least +/// once. +struct IconDetectorIdleHeuristicTests { + // MARK: - Idle prompt shapes + + @Test func detectsUserAtHostWithColonPath() { + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("onevcat@Mac:~/Sync/github/YiTong")) + } + + @Test func detectsUserAtHostWithSlashOnly() { + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("onevcat@Mac:/usr/local/etc")) + } + + @Test func detectsTildePath() { + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("~/Sync/github")) + } + + @Test func detectsAbsolutePath() { + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("/usr/local/bin")) + } + + @Test func detectsTruncatedPathWithEllipsis() { + // zsh's "compact path" renders as `…/Sync/github/YiTong`. + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("…/Sync/github/YiTong")) + } + + // MARK: - Real commands should not be flagged + + @Test func commandWithSpaceIsNotIdle() { + // Anything with a space is treated as a real command (program + + // args) — this is the primary discriminator. + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("git status")) + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("vim file.swift")) + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("docker compose up")) + } + + @Test func barCommandTokenIsNotIdle() { + // Single-token commands without `@`, `~`, `/`, or `…` are real + // commands. + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("claude")) + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("vim")) + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("npm")) + } + + @Test func tuiTitleIsNotIdle() { + // TUI tools that rewrite their own title (claude → spinner glyphs) + // contain spaces and should still be classified as commands. + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("✳ Claude Code")) + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("⠐ Claude Code")) + } + + @Test func emptyTitleIsNotIdle() { + // Empty handled by the caller; the heuristic itself returns false + // (no `@`, no leading `~`/`/`/`…`). + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("")) + } + + // MARK: - Edge cases + + @Test func atSymbolWithoutPathSeparatorIsNotIdle() { + // `git@github.com` would be a typical SSH remote, not an idle + // prompt. Without `:` or `/` it's not classified as idle. + #expect(!WorktreeTerminalState.isLikelyIdleTitleByShape("git@github.com")) + } + + @Test func absolutePathExecutableIsClassifiedAsIdle() { + // Documented limitation: `/usr/bin/python3` (rare invocation + // form) shape-matches as a "path" prompt and gets skipped. The + // tradeoff is fine — typical use is `python3`, not the absolute + // path. + #expect(WorktreeTerminalState.isLikelyIdleTitleByShape("/usr/bin/python3")) + } +} diff --git a/supacodeTests/TabIconSourceTests.swift b/supacodeTests/TabIconSourceTests.swift new file mode 100644 index 00000000..2c2f25bb --- /dev/null +++ b/supacodeTests/TabIconSourceTests.swift @@ -0,0 +1,70 @@ +import Testing + +@testable import supacode + +struct TabIconSourceTests { + // MARK: - storageString encoding + + @Test func sfSymbolOnlySerialisesBare() { + // SF-Symbol-only entries serialise as the bare symbol name so + // the existing IconPicker storage path keeps working unchanged. + let icon = TabIconSource(systemSymbol: "terminal") + #expect(icon.storageString == "terminal") + } + + @Test func assetEntrySerialisesWithMarker() { + // Asset-bearing entries get the `@asset:` prefix the renderer + // parses via `ResolvedTabIcon`. + let icon = TabIconSource(systemSymbol: "shippingbox", assetName: "Docker") + #expect(icon.storageString == "@asset:Docker") + } + + @Test func assetEntryOmitsSystemSymbolFromStorage() { + // `systemSymbol` stays only as a fallback for renderers that + // can't resolve the asset; storage carries the asset. + let icon = TabIconSource(systemSymbol: "sparkle", assetName: "ClaudeCode") + #expect(icon.storageString == "@asset:ClaudeCode") + #expect(!icon.storageString.contains("sparkle")) + } + + // MARK: - ResolvedTabIcon parsing + + @Test func parsesBareStringAsSystemSymbol() { + let resolved = ResolvedTabIcon.parse("terminal") + #expect(resolved == .systemSymbol("terminal")) + } + + @Test func parsesAssetMarker() { + let resolved = ResolvedTabIcon.parse("@asset:Docker") + #expect(resolved == .asset(name: "Docker")) + } + + @Test func parsesAssetMarkerWithSpaces() { + // Asset names can contain spaces (e.g. "Visual Studio Code"), so + // the parser must keep everything after the marker prefix intact. + let resolved = ResolvedTabIcon.parse("@asset:Visual Studio Code") + #expect(resolved == .asset(name: "Visual Studio Code")) + } + + @Test func sfSymbolStringWithColonStaysSymbol() { + // Edge: SF Symbol names never start with `@asset:`, so a literal + // colon-bearing symbol (none today, but defensive) doesn't trip + // the parser. + let resolved = ResolvedTabIcon.parse("foo:bar") + #expect(resolved == .systemSymbol("foo:bar")) + } + + // MARK: - Round-trip + + @Test func sfSymbolRoundTrip() { + let source = TabIconSource(systemSymbol: "hammer") + let parsed = ResolvedTabIcon.parse(source.storageString) + #expect(parsed == .systemSymbol("hammer")) + } + + @Test func assetRoundTrip() { + let source = TabIconSource(systemSymbol: "shippingbox", assetName: "Npm") + let parsed = ResolvedTabIcon.parse(source.storageString) + #expect(parsed == .asset(name: "Npm")) + } +}