diff --git a/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md
new file mode 100644
index 000000000..ec537d481
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md
@@ -0,0 +1,46 @@
+# Agent tab icons
+
+The SVGs in this folder are the agent logos shown on terminal tabs when Programa detects a coding agent in that terminal.
+
+- **Source:** https://github.com/lobehub/lobe-icons, `packages/static-svg/icons/`
+- **Pinned commit:** `a94750e3f5f8fc33757b839d85030e742284e43a`
+- **License:** MIT, Copyright (c) 2023 LobeHub (text below)
+
+| Asset | Lobe Icons file |
+|---|---|
+| `AgentIconClaudeCode` | `claudecode-color.svg` |
+| `AgentIconCodex` | `codex-color.svg` |
+| `AgentIconCopilot` | `githubcopilot.svg` |
+| `AgentIconCursor` | `cursor.svg` |
+| `AgentIconGeminiCLI` | `geminicli-color.svg` |
+| `AgentIconOpenCode` | `opencode.svg` |
+
+Aider has no icon in Lobe Icons, so Aider terminals keep the terminal symbol.
+
+Local changes to each file: the `
` element and the inline `style` attribute are removed, and `width`/`height` of `1em` become `24`. The monochrome logos (Copilot, Cursor, OpenCode) replace `fill="currentColor"` with `#000000` for the light appearance and a second file with `#FFFFFF` for the dark appearance. Path data is re-serialized with spaces between values and explicit arc flags, because Xcode's SVG compiler rejects the compact flag form; the geometry is identical.
+
+The logos are trademarks of their respective owners and are used here only to identify the tool running in a tab.
+
+## Lobe Icons license
+
+MIT License
+
+Copyright (c) 2023 LobeHub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/AgentIconClaudeCode.svg b/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/AgentIconClaudeCode.svg
new file mode 100644
index 000000000..c94a619ea
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/AgentIconClaudeCode.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/Contents.json
new file mode 100644
index 000000000..9a7bca1d8
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconClaudeCode.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/AgentIconCodex.svg b/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/AgentIconCodex.svg
new file mode 100644
index 000000000..a1b1e01e8
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/AgentIconCodex.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/Contents.json
new file mode 100644
index 000000000..5cd2a506f
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCodex.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconCodex.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot-dark.svg b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot-dark.svg
new file mode 100644
index 000000000..1524d9b89
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot-dark.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot.svg b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot.svg
new file mode 100644
index 000000000..de65e37a6
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/Contents.json
new file mode 100644
index 000000000..a253ae158
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconCopilot.svg",
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "filename": "AgentIconCopilot-dark.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor-dark.svg b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor-dark.svg
new file mode 100644
index 000000000..9b5db6a1c
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor-dark.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor.svg b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor.svg
new file mode 100644
index 000000000..86bcfd46a
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/Contents.json
new file mode 100644
index 000000000..4d9fe733f
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconCursor.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconCursor.svg",
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "filename": "AgentIconCursor-dark.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/AgentIconGeminiCLI.svg b/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/AgentIconGeminiCLI.svg
new file mode 100644
index 000000000..389c4449f
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/AgentIconGeminiCLI.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/Contents.json
new file mode 100644
index 000000000..59ff18681
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconGeminiCLI.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode-dark.svg b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode-dark.svg
new file mode 100644
index 000000000..359e358cb
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode-dark.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode.svg b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode.svg
new file mode 100644
index 000000000..9adb15036
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode.svg
@@ -0,0 +1 @@
+
diff --git a/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/Contents.json b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/Contents.json
new file mode 100644
index 000000000..8ddd64dd8
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "AgentIconOpenCode.svg",
+ "idiom": "universal"
+ },
+ {
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "filename": "AgentIconOpenCode-dark.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index 61d0099bc..724158be8 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -24,6 +24,7 @@
NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */; };
NRSP0074A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */; };
AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */; };
+ TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */; };
7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */; };
7501A0000000000000000001 /* ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000002 /* ProgramStatus.swift */; };
7501A0000000000000000007 /* TerminalController+SurfaceWaitProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */; };
@@ -417,6 +418,7 @@
NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Surfaces.swift"; sourceTree = ""; };
NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarTelemetry.swift"; sourceTree = ""; };
AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AgentActivityState.swift"; sourceTree = ""; };
+ TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalTabAgentIcon.swift"; sourceTree = ""; };
7501A0000000000000000002 /* ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatus.swift; sourceTree = ""; };
7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceWaitProgram.swift"; sourceTree = ""; };
7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+ProgramStatus.swift"; sourceTree = ""; };
@@ -865,6 +867,7 @@
NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */,
NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */,
AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */,
+ TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */,
7501A0000000000000000002 /* ProgramStatus.swift */,
7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */,
7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */,
@@ -1398,6 +1401,7 @@
NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */,
NRSP0074A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift in Sources */,
AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */,
+ TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */,
7501A0000000000000000001 /* ProgramStatus.swift in Sources */,
7501A0000000000000000007 /* TerminalController+SurfaceWaitProgram.swift in Sources */,
7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */,
diff --git a/Sources/AgentActivityState.swift b/Sources/AgentActivityState.swift
index 3fe9a4594..84abf3c3e 100644
--- a/Sources/AgentActivityState.swift
+++ b/Sources/AgentActivityState.swift
@@ -85,6 +85,9 @@ struct AgentPresence: Equatable, Sendable {
var source: AgentStateSource
var lastEventAt: Date
var sessionKey: AgentSessionKey?
+ /// Manifest id of the agent the screen-manifest engine recognized (`.inferred` source only;
+ /// inferred reports carry no session key, so this is the only record of who the agent is).
+ var inferredAgent: String?
/// The root record's state when `source == .program`; `state` holds its legacy projection
/// (done and error both read as `.idle`). Nil for every other source.
var programState: ProgramStatusState?
diff --git a/Sources/AgentScreenDetectionEngine.swift b/Sources/AgentScreenDetectionEngine.swift
index 3f5e7c0ee..29ba664d5 100644
--- a/Sources/AgentScreenDetectionEngine.swift
+++ b/Sources/AgentScreenDetectionEngine.swift
@@ -354,7 +354,8 @@ final class AgentScreenDetectionEngine: @unchecked Sendable {
workspace.updatePanelAgentState(
panelId: surfaceId,
state: resolvedState,
- source: .inferred
+ source: .inferred,
+ inferredAgent: manifest.agent
)
}
}
diff --git a/Sources/TerminalTabAgentIcon.swift b/Sources/TerminalTabAgentIcon.swift
new file mode 100644
index 000000000..a74fe5cac
--- /dev/null
+++ b/Sources/TerminalTabAgentIcon.swift
@@ -0,0 +1,76 @@
+// Agent logos on terminal tabs. A terminal whose panel has agent presence shows that agent's
+// logo (asset catalog `Assets.xcassets/AgentIcons`) instead of the terminal symbol; the logo
+// goes away when the presence clears. Both tab renderers (the SwiftUI bar in Bonsplit and the
+// AppKit glass bar in WindowPaneChromePortal) read `Tab.iconAsset`.
+
+import Bonsplit
+import Foundation
+
+enum TerminalTabAgentIconResolver {
+ /// Detected agent identifier -> asset name. Identifiers are the screen-manifest ids
+ /// (`Resources/AgentDetection/*.json`), the hook provider names, and the short names an agent
+ /// goes by in its own CLI. Aider is absent because Lobe Icons ships no Aider mark.
+ private static let assetNamesByIdentifier: [String: String] = [
+ "claude-code": "AgentIconClaudeCode",
+ "claude": "AgentIconClaudeCode",
+ "codex": "AgentIconCodex",
+ "copilot-cli": "AgentIconCopilot",
+ "copilot": "AgentIconCopilot",
+ "github-copilot": "AgentIconCopilot",
+ "cursor-agent": "AgentIconCursor",
+ "cursor": "AgentIconCursor",
+ "gemini-cli": "AgentIconGeminiCLI",
+ "gemini": "AgentIconGeminiCLI",
+ "opencode": "AgentIconOpenCode",
+ ]
+
+ static func assetName(forAgent identifier: String) -> String? {
+ let normalized = identifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return assetNamesByIdentifier[normalized]
+ }
+}
+
+extension Workspace {
+ /// The logo for one terminal panel's current agent, or nil when no agent is present or the
+ /// agent has no logo. Identity comes from the first source that names a known agent: the
+ /// hook session key, the screen-manifest id, then the OSC 7501 root record's `app`.
+ func terminalTabAgentIconAsset(forPanelId panelId: UUID) -> String? {
+ guard let presence = panelAgentPresence[panelId] else { return nil }
+ let identifiers = [
+ presence.sessionKey?.provider,
+ presence.inferredAgent,
+ presence.source == .program ? terminalPanel(for: panelId)?.programStatus.root?.app : nil,
+ ]
+ return identifiers.lazy.compactMap { $0 }.compactMap(TerminalTabAgentIconResolver.assetName(forAgent:)).first
+ }
+
+ /// Reconciles one terminal tab's logo with its panel's agent presence. No-op when the tab
+ /// already shows the right asset.
+ func syncTerminalTabAgentIconAsset(forPanelId panelId: UUID) {
+ guard panels[panelId] is TerminalPanel,
+ let tabId = surfaceIdFromPanelId(panelId),
+ let tab = bonsplitController.tab(tabId) else { return }
+ let asset = terminalTabAgentIconAsset(forPanelId: panelId)
+#if DEBUG
+ if let presence = panelAgentPresence[panelId] {
+ let app = terminalPanel(for: panelId)?.programStatus.root?.app
+ dlog(
+ "tab.agentIcon panel=\(panelId.uuidString.prefix(5)) source=\(presence.source) " +
+ "provider=\(presence.sessionKey?.provider ?? "nil") inferred=\(presence.inferredAgent ?? "nil") " +
+ "app=\(app ?? "nil") asset=\(asset ?? "nil")"
+ )
+ }
+#endif
+ guard tab.iconAsset != asset else { return }
+ bonsplitController.updateTab(tabId, iconAsset: .some(asset))
+ }
+
+ /// Runs after any write to `panelAgentPresence`, whichever path made it (hook, screen
+ /// inference, OSC 7501, sweep, detach/attach, prune).
+ func syncTerminalTabAgentIconAssets(previous: [UUID: AgentPresence]) {
+ for panelId in Set(previous.keys).union(panelAgentPresence.keys)
+ where previous[panelId] != panelAgentPresence[panelId] {
+ syncTerminalTabAgentIconAsset(forPanelId: panelId)
+ }
+ }
+}
diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift
index fd918bd5d..f1c5c1488 100644
--- a/Sources/WindowPaneChromePortal.swift
+++ b/Sources/WindowPaneChromePortal.swift
@@ -1206,6 +1206,8 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS
closeButton.contentTintColor = .secondaryLabelColor
if let data = tab.iconImageData, let image = NSImage(data: data) {
iconView.image = image
+ } else if let asset = tab.iconAsset, let image = NSImage(named: asset) {
+ iconView.image = image
} else if let icon = tab.icon {
// Unconfigured symbols render at the full 16pt slot; boxy filled
// glyphs like terminal.fill then dominate the pill. Match the
diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift
index 17f5eb11a..38f8083d6 100644
--- a/Sources/Workspace+Bonsplit.swift
+++ b/Sources/Workspace+Bonsplit.swift
@@ -855,6 +855,7 @@ extension Workspace: @preconcurrency BonsplitDelegate {
title: replacementPanel.displayTitle,
icon: .some(replacementPanel.displayIcon),
iconImageData: .some(nil),
+ iconAsset: .some(nil),
kind: .some(SurfaceKind.terminal),
hasCustomTitle: false,
isDirty: replacementPanel.isDirty,
diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift
index abbef6130..f429cfe33 100644
--- a/Sources/Workspace+SidebarTelemetry.swift
+++ b/Sources/Workspace+SidebarTelemetry.swift
@@ -243,6 +243,7 @@ extension Workspace {
state: AgentActivityState,
source: AgentStateSource = .hooks,
sessionKey: AgentSessionKey? = nil,
+ inferredAgent: String? = nil,
programState: ProgramStatusState? = nil,
at now: Date = Date()
) {
@@ -271,7 +272,10 @@ extension Workspace {
resolvedSessionKey = sessionKey ?? current?.sessionKey
}
+ let resolvedInferredAgent = source == .inferred ? inferredAgent : nil
+
guard current?.state != state || current?.source != source || current?.sessionKey != resolvedSessionKey
+ || current?.inferredAgent != resolvedInferredAgent
|| current?.programState != programState else {
// Same state from the same writer: only the liveness clock moves. Without this a
// long-running agent that keeps reporting `working` would read as stale at the
@@ -285,6 +289,7 @@ extension Workspace {
source: source,
lastEventAt: now,
sessionKey: resolvedSessionKey,
+ inferredAgent: resolvedInferredAgent,
programState: programState
)
// Event-driven half of surface.wait's `agent_state` condition (#166 task 2) and
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 4cfd10061..8ea4646ef 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -142,7 +142,9 @@ final class Workspace: Identifiable, ObservableObject {
/// by the agent-state-unification plan). This is the stored source of truth; `panelAgentStates`
/// and `panelAgentStateSources` below are derived, read-only mirrors kept for the existing
/// wire shapes (`agent_state`/`agent_state_source`) and call sites. See AgentActivityState.swift.
- @Published var panelAgentPresence: [UUID: AgentPresence] = [:]
+ @Published var panelAgentPresence: [UUID: AgentPresence] = [:] {
+ didSet { syncTerminalTabAgentIconAssets(previous: oldValue) }
+ }
/// Read-only mirror of `panelAgentPresence.state`, kept for existing wire serialization and
/// read call sites. Never write to this directly -- go through `updatePanelAgentState`.
var panelAgentStates: [UUID: AgentActivityState] {
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md
index 33c9eb56c..7ac9f1f5e 100644
--- a/THIRD_PARTY_LICENSES.md
+++ b/THIRD_PARTY_LICENSES.md
@@ -158,3 +158,30 @@ See the License for the specific language governing permissions and
limitations under the License.
+
+---
+
+## Lobe Icons
+
+- **License:** MIT License
+- **Copyright:** Copyright (c) 2023 LobeHub
+- **Source:** https://github.com/lobehub/lobe-icons
+- **Used for:** the agent logos in `Assets.xcassets/AgentIcons`, taken from commit `a94750e3f5f8fc33757b839d85030e742284e43a`
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/programaTests/AgentActivityStateTests.swift b/programaTests/AgentActivityStateTests.swift
index b803585cf..e09fef1be 100644
--- a/programaTests/AgentActivityStateTests.swift
+++ b/programaTests/AgentActivityStateTests.swift
@@ -635,3 +635,61 @@ final class AgentActivityStateTests: XCTestCase {
XCTAssertEqual(workspace.panelAgentPresence[panel]?.isStale(now: Date()), false)
}
}
+
+@MainActor
+final class TerminalTabAgentIconTests: XCTestCase {
+ func testResolverMapsEveryLogoAgentAndIgnoresCaseAndWhitespace() {
+ let expected: [String: String] = [
+ "claude-code": "AgentIconClaudeCode",
+ "codex": "AgentIconCodex",
+ "copilot-cli": "AgentIconCopilot",
+ "cursor-agent": "AgentIconCursor",
+ "gemini-cli": "AgentIconGeminiCLI",
+ "opencode": "AgentIconOpenCode",
+ ]
+ for (identifier, asset) in expected {
+ XCTAssertEqual(TerminalTabAgentIconResolver.assetName(forAgent: identifier), asset, identifier)
+ }
+ XCTAssertEqual(TerminalTabAgentIconResolver.assetName(forAgent: " Claude-Code\n"), "AgentIconClaudeCode")
+ XCTAssertEqual(TerminalTabAgentIconResolver.assetName(forAgent: "CODEX"), "AgentIconCodex")
+ }
+
+ func testResolverReturnsNilForAiderUnknownAndEmpty() {
+ XCTAssertNil(TerminalTabAgentIconResolver.assetName(forAgent: "aider"))
+ XCTAssertNil(TerminalTabAgentIconResolver.assetName(forAgent: "unknown"))
+ XCTAssertNil(TerminalTabAgentIconResolver.assetName(forAgent: ""))
+ }
+
+ func testTabIconAssetFollowsHookPresenceAndClearsWhenAgentLeaves() throws {
+ let manager = TabManager()
+ let workspace = try XCTUnwrap(manager.selectedWorkspace)
+ let panelId = try XCTUnwrap(workspace.focusedPanelId)
+ let tabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(panelId))
+ XCTAssertNil(workspace.bonsplitController.tab(tabId)?.iconAsset)
+
+ workspace.updatePanelAgentState(
+ panelId: panelId,
+ state: .working,
+ source: .hooks,
+ sessionKey: AgentSessionKey(provider: "codex", sessionId: "s1", pid: nil)
+ )
+ XCTAssertEqual(workspace.bonsplitController.tab(tabId)?.iconAsset, "AgentIconCodex")
+
+ workspace.clearPanelAgentState(panelId: panelId)
+ XCTAssertNil(workspace.bonsplitController.tab(tabId)?.iconAsset)
+ }
+
+ func testTabIconAssetFollowsInferredAgentAndSkipsAgentsWithoutLogo() throws {
+ let manager = TabManager()
+ let workspace = try XCTUnwrap(manager.selectedWorkspace)
+ let panelId = try XCTUnwrap(workspace.focusedPanelId)
+ let tabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(panelId))
+
+ workspace.updatePanelAgentState(panelId: panelId, state: .working, source: .inferred, inferredAgent: "gemini-cli")
+ XCTAssertEqual(workspace.bonsplitController.tab(tabId)?.iconAsset, "AgentIconGeminiCLI")
+
+ workspace.clearPanelAgentState(panelId: panelId)
+ workspace.updatePanelAgentState(panelId: panelId, state: .working, source: .inferred, inferredAgent: "aider")
+ XCTAssertNil(workspace.bonsplitController.tab(tabId)?.iconAsset)
+ }
+}
diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift
index 411cc4134..20fa5ba8d 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift
@@ -20,6 +20,7 @@ struct TabItem: Identifiable, Hashable, Codable {
var hasCustomTitle: Bool
var icon: String?
var iconImageData: Data?
+ var iconAsset: String?
var kind: String?
var isDirty: Bool
var showsNotificationBadge: Bool
@@ -33,6 +34,7 @@ struct TabItem: Identifiable, Hashable, Codable {
hasCustomTitle: Bool = false,
icon: String? = "doc.text",
iconImageData: Data? = nil,
+ iconAsset: String? = nil,
kind: String? = nil,
isDirty: Bool = false,
showsNotificationBadge: Bool = false,
@@ -45,6 +47,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.hasCustomTitle = hasCustomTitle
self.icon = icon
self.iconImageData = iconImageData
+ self.iconAsset = iconAsset
self.kind = kind
self.isDirty = isDirty
self.showsNotificationBadge = showsNotificationBadge
@@ -60,6 +63,7 @@ struct TabItem: Identifiable, Hashable, Codable {
hasCustomTitle: tab.hasCustomTitle,
icon: tab.icon,
iconImageData: tab.iconImageData,
+ iconAsset: tab.iconAsset,
kind: tab.kind,
isDirty: tab.isDirty,
showsNotificationBadge: tab.showsNotificationBadge,
@@ -83,6 +87,7 @@ struct TabItem: Identifiable, Hashable, Codable {
case hasCustomTitle
case icon
case iconImageData
+ case iconAsset
case kind
case isDirty
case showsNotificationBadge
@@ -98,6 +103,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.hasCustomTitle = try c.decodeIfPresent(Bool.self, forKey: .hasCustomTitle) ?? false
self.icon = try c.decodeIfPresent(String.self, forKey: .icon)
self.iconImageData = try c.decodeIfPresent(Data.self, forKey: .iconImageData)
+ self.iconAsset = try c.decodeIfPresent(String.self, forKey: .iconAsset)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false
self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false
@@ -113,6 +119,7 @@ struct TabItem: Identifiable, Hashable, Codable {
try c.encode(hasCustomTitle, forKey: .hasCustomTitle)
try c.encodeIfPresent(icon, forKey: .icon)
try c.encodeIfPresent(iconImageData, forKey: .iconImageData)
+ try c.encodeIfPresent(iconAsset, forKey: .iconAsset)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encode(isDirty, forKey: .isDirty)
try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge)
diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift
index 19882748e..27fe911a2 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift
@@ -284,6 +284,7 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable {
title: tab.title,
icon: tab.icon,
iconImageData: tab.iconImageData,
+ iconAsset: tab.iconAsset,
isSelected: selectedTabID == tab.id,
isPinned: tab.isPinned,
isDirty: tab.isDirty,
@@ -633,6 +634,7 @@ struct PaneContainerView: View {
hasher.combine(tab.hasCustomTitle)
hasher.combine(tab.icon)
hasher.combine(tab.iconImageData)
+ hasher.combine(tab.iconAsset)
hasher.combine(tab.kind)
hasher.combine(tab.isDirty)
hasher.combine(tab.showsNotificationBadge)
diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift
index ab5838e1d..7330eea6b 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift
@@ -104,6 +104,12 @@ struct TabItemView: View {
FaviconIconView(image: image)
.frame(width: iconSlotSize, height: iconSlotSize, alignment: .center)
.clipped()
+ } else if let iconAsset = tab.iconAsset {
+ Image(iconAsset, bundle: .main)
+ .resizable()
+ .scaledToFit()
+ .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center)
+ .clipped()
} else if let iconName = tab.icon {
if iconName == "globe", !showGlobeFallback {
// Avoid a distracting "globe -> favicon" flash: show a neutral placeholder
@@ -118,8 +124,8 @@ struct TabItemView: View {
}
}
}
- // Keep downloaded favicon bitmaps in full color even for inactive tab bars.
- .saturation(TabItemStyling.iconSaturation(hasRasterIcon: faviconImage != nil, tabSaturation: saturation))
+ // Keep downloaded favicon bitmaps and agent logos in full color even for inactive tab bars.
+ .saturation(TabItemStyling.iconSaturation(hasRasterIcon: faviconImage != nil || tab.iconAsset != nil, tabSaturation: saturation))
.transaction { tx in
// Prevent incidental parent animations from briefly fading icon content.
tx.animation = nil
diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift
index 73e933df2..ac7ec1185 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift
@@ -93,6 +93,7 @@ public final class BonsplitController {
/// - title: The tab title
/// - icon: Optional SF Symbol name for the tab icon
/// - iconImageData: Optional image data (PNG recommended) for the tab icon. When present, takes precedence over `icon`.
+ /// - iconAsset: Optional asset-catalog image name (host main bundle). Takes precedence over `icon`, below `iconImageData`.
/// - kind: Consumer-defined tab kind identifier (e.g. "terminal", "markdown")
/// - hasCustomTitle: Whether the tab title came from a custom user override
/// - isDirty: Whether the tab shows a dirty indicator
@@ -107,6 +108,7 @@ public final class BonsplitController {
hasCustomTitle: Bool = false,
icon: String? = "doc.text",
iconImageData: Data? = nil,
+ iconAsset: String? = nil,
kind: String? = nil,
isDirty: Bool = false,
showsNotificationBadge: Bool = false,
@@ -122,6 +124,7 @@ public final class BonsplitController {
hasCustomTitle: hasCustomTitle,
icon: icon,
iconImageData: iconImageData,
+ iconAsset: iconAsset,
kind: kind,
isDirty: isDirty,
showsNotificationBadge: showsNotificationBadge,
@@ -187,6 +190,7 @@ public final class BonsplitController {
/// - title: New title (pass nil to keep current)
/// - icon: New icon (pass nil to keep current, pass .some(nil) to remove icon)
/// - iconImageData: New icon image data (pass nil to keep current, pass .some(nil) to remove)
+ /// - iconAsset: New asset-catalog image name (pass nil to keep current, pass .some(nil) to remove)
/// - kind: New tab kind (pass nil to keep current, pass .some(nil) to clear)
/// - hasCustomTitle: New custom-title state (pass nil to keep current)
/// - isDirty: New dirty state (pass nil to keep current)
@@ -198,6 +202,7 @@ public final class BonsplitController {
title: String? = nil,
icon: String?? = nil,
iconImageData: Data?? = nil,
+ iconAsset: String?? = nil,
kind: String?? = nil,
hasCustomTitle: Bool? = nil,
isDirty: Bool? = nil,
@@ -217,6 +222,9 @@ public final class BonsplitController {
if let iconImageData = iconImageData {
pane.tabs[tabIndex].iconImageData = iconImageData
}
+ if let iconAsset = iconAsset {
+ pane.tabs[tabIndex].iconAsset = iconAsset
+ }
if let kind = kind {
pane.tabs[tabIndex].kind = kind
}
diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift
index 4d7eebce1..eb8d055b6 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift
@@ -17,6 +17,7 @@ public struct BonsplitPaneChromeTabDescriptor {
public let title: String
public let icon: String?
public let iconImageData: Data?
+ public let iconAsset: String?
public let isSelected: Bool
public let isPinned: Bool
public let isDirty: Bool
@@ -31,6 +32,7 @@ public struct BonsplitPaneChromeTabDescriptor {
title: String,
icon: String?,
iconImageData: Data?,
+ iconAsset: String? = nil,
isSelected: Bool,
isPinned: Bool,
isDirty: Bool,
@@ -43,6 +45,7 @@ public struct BonsplitPaneChromeTabDescriptor {
self.title = title
self.icon = icon
self.iconImageData = iconImageData
+ self.iconAsset = iconAsset
self.isSelected = isSelected
self.isPinned = isPinned
self.isDirty = isDirty
diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift
index 2c412d03a..6bb416adf 100644
--- a/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift
+++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift
@@ -8,6 +8,8 @@ public struct Tab: Identifiable, Hashable, Sendable {
public let icon: String?
/// Optional image data (PNG recommended) for the tab icon. When present, this takes precedence over `icon`.
public let iconImageData: Data?
+ /// Optional asset-catalog image name, resolved in the host app's main bundle. Takes precedence over `icon` but not over `iconImageData`.
+ public let iconAsset: String?
/// Consumer-defined tab kind identifier (for example, "terminal" or "markdown").
public let kind: String?
public let isDirty: Bool
@@ -26,6 +28,7 @@ public struct Tab: Identifiable, Hashable, Sendable {
hasCustomTitle: Bool = false,
icon: String? = nil,
iconImageData: Data? = nil,
+ iconAsset: String? = nil,
kind: String? = nil,
isDirty: Bool = false,
showsNotificationBadge: Bool = false,
@@ -38,6 +41,7 @@ public struct Tab: Identifiable, Hashable, Sendable {
self.hasCustomTitle = hasCustomTitle
self.icon = icon
self.iconImageData = iconImageData
+ self.iconAsset = iconAsset
self.kind = kind
self.isDirty = isDirty
self.showsNotificationBadge = showsNotificationBadge
@@ -52,6 +56,7 @@ public struct Tab: Identifiable, Hashable, Sendable {
self.hasCustomTitle = tabItem.hasCustomTitle
self.icon = tabItem.icon
self.iconImageData = tabItem.iconImageData
+ self.iconAsset = tabItem.iconAsset
self.kind = tabItem.kind
self.isDirty = tabItem.isDirty
self.showsNotificationBadge = tabItem.showsNotificationBadge