From ec89b53f7ca86aedf2f94bb65fa4115699db3b22 Mon Sep 17 00:00:00 2001 From: vanvasten Date: Fri, 16 Oct 2026 15:09:35 -0300 Subject: [PATCH] feat: show the agent's logo on terminal tabs running Claude, Codex and other agents A terminal tab whose agent is detected now shows that agent's logo (Claude Code, Codex, Copilot, Cursor, Gemini CLI, OpenCode) in place of the generic terminal glyph, in both the native and SwiftUI tab strips. Logos come from Lobe Icons (MIT), attributed in THIRD_PARTY_LICENSES.md. --- Assets.xcassets/AgentIcons/ATTRIBUTIONS.md | 46 +++++++++++ .../AgentIconClaudeCode.svg | 1 + .../Contents.json | 15 ++++ .../AgentIconCodex.svg | 1 + .../AgentIconCodex.imageset/Contents.json | 15 ++++ .../AgentIconCopilot-dark.svg | 1 + .../AgentIconCopilot.svg | 1 + .../AgentIconCopilot.imageset/Contents.json | 25 ++++++ .../AgentIconCursor-dark.svg | 1 + .../AgentIconCursor.svg | 1 + .../AgentIconCursor.imageset/Contents.json | 25 ++++++ .../AgentIconGeminiCLI.svg | 1 + .../AgentIconGeminiCLI.imageset/Contents.json | 15 ++++ .../AgentIconOpenCode-dark.svg | 1 + .../AgentIconOpenCode.svg | 1 + .../AgentIconOpenCode.imageset/Contents.json | 25 ++++++ GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AgentActivityState.swift | 3 + Sources/AgentScreenDetectionEngine.swift | 3 +- Sources/TerminalTabAgentIcon.swift | 76 +++++++++++++++++++ Sources/WindowPaneChromePortal.swift | 2 + Sources/Workspace+Bonsplit.swift | 1 + Sources/Workspace+SidebarTelemetry.swift | 5 ++ Sources/Workspace.swift | 4 +- THIRD_PARTY_LICENSES.md | 27 +++++++ programaTests/AgentActivityStateTests.swift | 58 ++++++++++++++ .../Bonsplit/Internal/Models/TabItem.swift | 7 ++ .../Internal/Views/PaneContainerView.swift | 2 + .../Bonsplit/Internal/Views/TabItemView.swift | 10 ++- .../Bonsplit/Public/BonsplitController.swift | 8 ++ .../BonsplitPaneChromePortalBridge.swift | 3 + .../Sources/Bonsplit/Public/Types/Tab.swift | 5 ++ 32 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 Assets.xcassets/AgentIcons/ATTRIBUTIONS.md create mode 100644 Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/AgentIconClaudeCode.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconClaudeCode.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/AgentIconCodex.imageset/AgentIconCodex.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconCodex.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot-dark.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/AgentIconCopilot.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconCopilot.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor-dark.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconCursor.imageset/AgentIconCursor.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconCursor.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/AgentIconGeminiCLI.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconGeminiCLI.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode-dark.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/AgentIconOpenCode.svg create mode 100644 Assets.xcassets/AgentIcons/AgentIconOpenCode.imageset/Contents.json create mode 100644 Sources/TerminalTabAgentIcon.swift 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 @@ +<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg> 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 @@ +<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M19.503 0H4.496A4.496 4.496 0 0 0 0 4.496v15.007A4.496 4.496 0 0 0 4.496 24h15.007A4.496 4.496 0 0 0 24 19.503V4.496A4.496 4.496 0 0 0 19.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 0 1 2.285 -.312c1 .115 1.891 .54 2.673 1.275 .01 .01 .024 .017 .037 .021a.09 .09 0 0 0 .043 0 4.55 4.55 0 0 1 3.046 .275l.047 .022 .116 .057a4.581 4.581 0 0 1 2.188 2.399c.209 .51 .313 1.041 .315 1.595a4.24 4.24 0 0 1 -.134 1.223 .123 .123 0 0 0 .03 .115c.594 .607 .988 1.33 1.183 2.17 .289 1.425 -.007 2.71 -.887 3.854l-.136 .166a4.548 4.548 0 0 1 -2.201 1.388 .123 .123 0 0 0 -.081 .076c-.191 .551 -.383 1.023 -.74 1.494 -.9 1.187 -2.222 1.846 -3.711 1.838 -1.187 -.006 -2.239 -.44 -3.157 -1.302a.107 .107 0 0 0 -.105 -.024c-.388 .125 -.78 .143 -1.204 .138a4.441 4.441 0 0 1 -1.945 -.466 4.544 4.544 0 0 1 -1.61 -1.335c-.152 -.202 -.303 -.392 -.414 -.617a5.81 5.81 0 0 1 -.37 -.961 4.582 4.582 0 0 1 -.014 -2.298 .124 .124 0 0 0 .006 -.056 .085 .085 0 0 0 -.027 -.048 4.467 4.467 0 0 1 -1.034 -1.651 3.896 3.896 0 0 1 -.251 -1.192 5.189 5.189 0 0 1 .141 -1.6c.337 -1.112 .982 -1.985 1.933 -2.618 .212 -.141 .413 -.251 .601 -.33 .215 -.089 .43 -.164 .646 -.227a.098 .098 0 0 0 .065 -.066 4.51 4.51 0 0 1 .829 -1.615 4.535 4.535 0 0 1 1.837 -1.388zm3.482 10.565a.637 .637 0 0 0 0 1.272h3.636a.637 .637 0 1 0 0 -1.272h-3.636zM8.462 9.23a.637 .637 0 0 0 -1.106 .631l1.272 2.224 -1.266 2.136a.636 .636 0 1 0 1.095 .649l1.454 -2.455a.636 .636 0 0 0 .005 -.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg> 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 @@ +<svg fill="#FFFFFF" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817 .622 0 1.2 .135 1.592 .654l.73 .964c.21 .278 .323 .61 .323 .955v2.62c0 .339 -.173 .669 -.453 .868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0 -9.205 -2.583 -11.547 -4.258 -.28 -.2 -.452 -.53 -.453 -.868v-2.62c0 -.345 .113 -.679 .321 -.956l.73 -.963c.392 -.517 .974 -.654 1.593 -.654l.029 -.297c.25 -2.446 .81 -4.213 2.082 -5.52 2.461 -2.54 5.71 -2.851 7.146 -2.864h.198c1.436 .013 4.685 .323 7.146 2.864zm-7.244 4.328c-.284 0 -.613 .016 -.962 .05 -.123 .447 -.305 .85 -.57 1.108 -1.05 1.023 -2.316 1.18 -2.994 1.18 -.638 0 -1.306 -.13 -1.851 -.464 -.516 .165 -1.012 .403 -1.044 .996a65.882 65.882 0 0 0 -.063 2.884l-.002 .48c-.002 .563 -.005 1.126 -.013 1.69 .002 .326 .204 .63 .51 .765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504 -.555 6.985 -1.657a.854 .854 0 0 0 .51 -.766c.03 -1.682 .006 -3.372 -.076 -5.053 -.031 -.596 -.528 -.83 -1.046 -.996 -.546 .333 -1.212 .464 -1.85 .464 -.677 0 -1.942 -.157 -2.993 -1.18 -.266 -.258 -.447 -.661 -.57 -1.108 -.32 -.032 -.64 -.049 -.96 -.05zm-2.525 4.013c.539 0 .976 .426 .976 .95v1.753c0 .525 -.437 .95 -.976 .95a.964 .964 0 0 1 -.976 -.95v-1.752c0 -.525 .437 -.951 .976 -.951zm5 0c.539 0 .976 .426 .976 .95v1.753c0 .525 -.437 .95 -.976 .95a.964 .964 0 0 1 -.976 -.95v-1.752c0 -.525 .437 -.951 .976 -.951zM7.635 5.087c-1.05 .102 -1.935 .438 -2.385 .906 -.975 1.037 -.765 3.668 -.21 4.224 .405 .394 1.17 .657 1.995 .657h.09c.649 -.013 1.785 -.176 2.73 -1.11 .435 -.41 .705 -1.433 .675 -2.47 -.03 -.834 -.27 -1.52 -.63 -1.813 -.39 -.336 -1.275 -.482 -2.265 -.394zm6.465 .394c-.36 .292 -.6 .98 -.63 1.813 -.03 1.037 .24 2.06 .675 2.47 .968 .957 2.136 1.104 2.776 1.11h.044c.825 0 1.59 -.263 1.995 -.657 .555 -.556 .765 -3.187 -.21 -4.224 -.45 -.468 -1.335 -.804 -2.385 -.906 -.99 -.088 -1.875 .058 -2.265 .394zM12 7.615c-.24 0 -.525 .015 -.84 .044 .03 .16 .045 .336 .06 .526l-.001 .159a2.94 2.94 0 0 1 -.014 .25c.225 -.022 .425 -.027 .612 -.028h.366c.187 0 .387 .006 .612 .028 -.015 -.146 -.015 -.277 -.015 -.409 .015 -.19 .03 -.365 .06 -.526a9.29 9.29 0 0 0 -.84 -.044z"></path></svg> 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 @@ +<svg fill="#000000" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817 .622 0 1.2 .135 1.592 .654l.73 .964c.21 .278 .323 .61 .323 .955v2.62c0 .339 -.173 .669 -.453 .868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0 -9.205 -2.583 -11.547 -4.258 -.28 -.2 -.452 -.53 -.453 -.868v-2.62c0 -.345 .113 -.679 .321 -.956l.73 -.963c.392 -.517 .974 -.654 1.593 -.654l.029 -.297c.25 -2.446 .81 -4.213 2.082 -5.52 2.461 -2.54 5.71 -2.851 7.146 -2.864h.198c1.436 .013 4.685 .323 7.146 2.864zm-7.244 4.328c-.284 0 -.613 .016 -.962 .05 -.123 .447 -.305 .85 -.57 1.108 -1.05 1.023 -2.316 1.18 -2.994 1.18 -.638 0 -1.306 -.13 -1.851 -.464 -.516 .165 -1.012 .403 -1.044 .996a65.882 65.882 0 0 0 -.063 2.884l-.002 .48c-.002 .563 -.005 1.126 -.013 1.69 .002 .326 .204 .63 .51 .765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504 -.555 6.985 -1.657a.854 .854 0 0 0 .51 -.766c.03 -1.682 .006 -3.372 -.076 -5.053 -.031 -.596 -.528 -.83 -1.046 -.996 -.546 .333 -1.212 .464 -1.85 .464 -.677 0 -1.942 -.157 -2.993 -1.18 -.266 -.258 -.447 -.661 -.57 -1.108 -.32 -.032 -.64 -.049 -.96 -.05zm-2.525 4.013c.539 0 .976 .426 .976 .95v1.753c0 .525 -.437 .95 -.976 .95a.964 .964 0 0 1 -.976 -.95v-1.752c0 -.525 .437 -.951 .976 -.951zm5 0c.539 0 .976 .426 .976 .95v1.753c0 .525 -.437 .95 -.976 .95a.964 .964 0 0 1 -.976 -.95v-1.752c0 -.525 .437 -.951 .976 -.951zM7.635 5.087c-1.05 .102 -1.935 .438 -2.385 .906 -.975 1.037 -.765 3.668 -.21 4.224 .405 .394 1.17 .657 1.995 .657h.09c.649 -.013 1.785 -.176 2.73 -1.11 .435 -.41 .705 -1.433 .675 -2.47 -.03 -.834 -.27 -1.52 -.63 -1.813 -.39 -.336 -1.275 -.482 -2.265 -.394zm6.465 .394c-.36 .292 -.6 .98 -.63 1.813 -.03 1.037 .24 2.06 .675 2.47 .968 .957 2.136 1.104 2.776 1.11h.044c.825 0 1.59 -.263 1.995 -.657 .555 -.556 .765 -3.187 -.21 -4.224 -.45 -.468 -1.335 -.804 -2.385 -.906 -.99 -.088 -1.875 .058 -2.265 .394zM12 7.615c-.24 0 -.525 .015 -.84 .044 .03 .16 .045 .336 .06 .526l-.001 .159a2.94 2.94 0 0 1 -.014 .25c.225 -.022 .425 -.027 .612 -.028h.366c.187 0 .387 .006 .612 .028 -.015 -.146 -.015 -.277 -.015 -.409 .015 -.19 .03 -.365 .06 -.526a9.29 9.29 0 0 0 -.84 -.044z"></path></svg> 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 @@ +<svg fill="#FFFFFF" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M22.106 5.68L12.5 .135a.998 .998 0 0 0 -.998 0L1.893 5.68a.84 .84 0 0 0 -.419 .726v11.186c0 .3 .16 .577 .42 .727l9.607 5.547a.999 .999 0 0 0 .998 0l9.608 -5.547a.84 .84 0 0 0 .42 -.727V6.407a.84 .84 0 0 0 -.42 -.726zm-.603 1.176L12.228 22.92c-.063 .108 -.228 .064 -.228 -.061V12.34a.59 .59 0 0 0 -.295 -.51l-9.11 -5.26c-.107 -.062 -.063 -.228 .062 -.228h18.55c.264 0 .428 .286 .296 .514z"></path></svg> 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 @@ +<svg fill="#000000" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M22.106 5.68L12.5 .135a.998 .998 0 0 0 -.998 0L1.893 5.68a.84 .84 0 0 0 -.419 .726v11.186c0 .3 .16 .577 .42 .727l9.607 5.547a.999 .999 0 0 0 .998 0l9.608 -5.547a.84 .84 0 0 0 .42 -.727V6.407a.84 .84 0 0 0 -.42 -.726zm-.603 1.176L12.228 22.92c-.063 .108 -.228 .064 -.228 -.061V12.34a.59 .59 0 0 0 -.295 -.51l-9.11 -5.26c-.107 -.062 -.063 -.228 .062 -.228h18.55c.264 0 .428 .286 .296 .514z"></path></svg> 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 @@ +<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 4.391A4.391 4.391 0 0 1 4.391 0h15.217A4.391 4.391 0 0 1 24 4.391v15.217A4.391 4.391 0 0 1 19.608 24H4.391A4.391 4.391 0 0 1 0 19.608V4.391z" fill="url(#lobe-icons-gemini-cli-_R_0_)"></path><path clip-rule="evenodd" d="M19.74 1.444a2.816 2.816 0 0 1 2.816 2.816v15.48a2.816 2.816 0 0 1 -2.816 2.816H4.26a2.816 2.816 0 0 1 -2.816 -2.816V4.26A2.816 2.816 0 0 1 4.26 1.444h15.48zM7.236 8.564l7.752 3.728 -7.752 3.727v2.802l9.557 -4.596v-3.866L7.236 5.763v2.801z" fill="#1E1E2E" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-cli-_R_0_" x1="24" x2="0" y1="6.587" y2="16.494"><stop stop-color="#EE4D5D"></stop><stop offset=".328" stop-color="#B381DD"></stop><stop offset=".476" stop-color="#207CFE"></stop></linearGradient></defs></svg> 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 @@ +<svg fill="#FFFFFF" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg> 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 @@ +<svg fill="#000000" fill-rule="evenodd" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg> 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 = "<group>"; }; NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarTelemetry.swift"; sourceTree = "<group>"; }; AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AgentActivityState.swift"; sourceTree = "<group>"; }; + TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalTabAgentIcon.swift"; sourceTree = "<group>"; }; 7501A0000000000000000002 /* ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatus.swift; sourceTree = "<group>"; }; 7501A0000000000000000008 /* TerminalController+SurfaceWaitProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceWaitProgram.swift"; sourceTree = "<group>"; }; 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+ProgramStatus.swift"; sourceTree = "<group>"; }; @@ -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<Content: View, EmptyContent: View>: 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