From acb62263f4c57d10af3ae38dc5c9b548ae42abc6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:34:33 -0700 Subject: [PATCH 1/9] feat(cloud): brand agent terminal icons by provider --- Assets.xcassets/AgentIcons/ATTRIBUTIONS.md | 5 ++ .../CodeBuddy.imageset/CodeBuddy.svg | 1 + .../CodeBuddy.imageset/Contents.json | 15 ++++++ .../AgentIcons/Copilot.imageset/Contents.json | 25 +++++++++ .../Copilot.imageset/Copilot-dark.svg | 1 + .../AgentIcons/Copilot.imageset/Copilot.svg | 1 + .../AgentIcons/Cursor.imageset/Contents.json | 25 +++++++++ .../Cursor.imageset/Cursor-dark.svg | 1 + .../AgentIcons/Cursor.imageset/Cursor.svg | 1 + .../AgentIcons/Factory.imageset/Contents.json | 15 ++++++ .../AgentIcons/Factory.imageset/Factory.svg | 8 +++ .../AgentIcons/Gemini.imageset/Contents.json | 15 ++++++ .../AgentIcons/Gemini.imageset/Gemini.svg | 1 + .../AgentIcons/Kimi.imageset/Contents.json | 15 ++++++ .../AgentIcons/Kimi.imageset/Kimi.svg | 1 + .../AgentIcons/Kiro.imageset/Contents.json | 15 ++++++ .../AgentIcons/Kiro.imageset/Kiro.svg | 1 + Assets.xcassets/AgentIcons/LOBE-LICENSE.txt | 21 ++++++++ .../AgentIcons/Ollama.imageset/Contents.json | 25 +++++++++ .../Ollama.imageset/Ollama-dark.svg | 1 + .../AgentIcons/Ollama.imageset/Ollama.svg | 1 + .../AgentIcons/Qoder.imageset/Contents.json | 25 +++++++++ .../AgentIcons/Qoder.imageset/Qoder-dark.svg | 1 + .../AgentIcons/Qoder.imageset/Qoder.svg | 1 + Sources/Cloud/CloudTreeRowContentView.swift | 18 +++++-- Sources/Cloud/CloudTreeRowIcon.swift | 20 +++++++ ...anagerCodingAgentDefinition+BuiltIns.swift | 22 ++++---- Sources/Surfaces/CmuxTuiSnapshotParser.swift | 25 +++++++-- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 5 ++ .../Surfaces/SurfaceCatalog+AgentIcons.swift | 24 +++++++++ Sources/Surfaces/SurfaceCatalog.swift | 8 +++ Sources/Surfaces/SurfaceCatalogModel.swift | 3 ++ ...SurfacePaneFactory+CloudManualMirror.swift | 2 + .../Surfaces/SurfaceResource+AgentIcon.swift | 18 +++++++ .../Workspace+CloudManualMirror.swift | 31 ++++++++--- Sources/TerminalTabAgentIcon.swift | 52 +++++++++++++++++++ Sources/Workspace+PanelLifecycle.swift | 7 +++ Sources/Workspace+TitleOwnership.swift | 4 ++ THIRD_PARTY_LICENSES.md | 12 +++++ cmux.xcodeproj/project.pbxproj | 16 ++++++ cmuxTests/CloudSidebarConsistencyTests.swift | 20 +++++++ cmuxTests/CmuxTuiSurfaceProviderTests.swift | 10 ++++ 42 files changed, 491 insertions(+), 27 deletions(-) create mode 100644 Assets.xcassets/AgentIcons/ATTRIBUTIONS.md create mode 100644 Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg create mode 100644 Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg create mode 100644 Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg create mode 100644 Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg create mode 100644 Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg create mode 100644 Assets.xcassets/AgentIcons/Factory.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg create mode 100644 Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg create mode 100644 Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg create mode 100644 Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg create mode 100644 Assets.xcassets/AgentIcons/LOBE-LICENSE.txt create mode 100644 Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg create mode 100644 Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg create mode 100644 Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg create mode 100644 Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg create mode 100644 Sources/Surfaces/SurfaceCatalog+AgentIcons.swift create mode 100644 Sources/Surfaces/SurfaceResource+AgentIcon.swift 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 000000000000..0f5418c1b525 --- /dev/null +++ b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md @@ -0,0 +1,5 @@ +# Agent icons + +Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi and Ollama SVGs come from [Lobe Icons](https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons) under the MIT license in `LOBE-LICENSE.txt`. Original SVG paths are unchanged; currentColor is replaced with black and white for light and dark appearances. + +Factory uses its [published site icon](https://factory.ai/icon.svg), retrieved on 2026-09-17. The redundant outer SVG wrapper is removed for asset-catalog compatibility. diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg new file mode 100644 index 000000000000..ec353e26418e --- /dev/null +++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg @@ -0,0 +1 @@ +CodeBuddy \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json new file mode 100644 index 000000000000..4700385540b8 --- /dev/null +++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "CodeBuddy.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json new file mode 100644 index 000000000000..29834aaeedf3 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Copilot.svg", + "idiom": "universal" + }, + { + "filename": "Copilot-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg new file mode 100644 index 000000000000..ef5e74f4541f --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg new file mode 100644 index 000000000000..fd0bc9ed7aa2 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json new file mode 100644 index 000000000000..fefa738bedee --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Cursor.svg", + "idiom": "universal" + }, + { + "filename": "Cursor-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg new file mode 100644 index 000000000000..4b6f0ed522cc --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg new file mode 100644 index 000000000000..f0cb42874a75 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json new file mode 100644 index 000000000000..efc8a52ab5a1 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Factory.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg new file mode 100644 index 000000000000..38aad643a75e --- /dev/null +++ b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json new file mode 100644 index 000000000000..e237b2d5b5fd --- /dev/null +++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Gemini.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg new file mode 100644 index 000000000000..e15f1f7efae4 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json new file mode 100644 index 000000000000..1eb219aa99fc --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Kimi.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg new file mode 100644 index 000000000000..83878fa28476 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json new file mode 100644 index 000000000000..c5437ff6ad83 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Kiro.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg new file mode 100644 index 000000000000..0c651b9747d0 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg @@ -0,0 +1 @@ +Kiro \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt new file mode 100644 index 000000000000..1dd53d2a9d99 --- /dev/null +++ b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt @@ -0,0 +1,21 @@ +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/Ollama.imageset/Contents.json b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json new file mode 100644 index 000000000000..53532f35516e --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Ollama.svg", + "idiom": "universal" + }, + { + "filename": "Ollama-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg new file mode 100644 index 000000000000..a758e0f55587 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg new file mode 100644 index 000000000000..06947b8d698a --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json new file mode 100644 index 000000000000..14cc6097d0ab --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Qoder.svg", + "idiom": "universal" + }, + { + "filename": "Qoder-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg new file mode 100644 index 000000000000..1301a9de4582 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg new file mode 100644 index 000000000000..36ffff45c37d --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 03c4896d7127..40d73af58697 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -175,6 +175,7 @@ struct CloudTreeLeafRow: View { let style: CloudTreeStyle let icon: String let tint: Color + var iconAsset: String? = nil let title: String var titleWeight: Font.Weight = .regular var titleDimmed: Bool = false @@ -189,6 +190,7 @@ struct CloudTreeLeafRow: View { style: CloudTreeStyle, icon: String, tint: Color, + iconAsset: String? = nil, title: String, titleWeight: Font.Weight = .regular, titleDimmed: Bool = false, @@ -199,6 +201,7 @@ struct CloudTreeLeafRow: View { self.style = style self.icon = icon self.tint = tint + self.iconAsset = iconAsset self.title = title self.titleWeight = titleWeight self.titleDimmed = titleDimmed @@ -210,7 +213,13 @@ struct CloudTreeLeafRow: View { var body: some View { HStack(alignment: .center, spacing: style.iconGap) { if style.iconSlot > 0 { - CloudTreeRowIcon(style: style, systemName: icon, tint: tint, dimmed: titleDimmed) + CloudTreeRowIcon( + style: style, + systemName: icon, + tint: tint, + assetName: iconAsset, + dimmed: titleDimmed + ) } switch style.leafLayout { case .twoLine: @@ -276,6 +285,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { style: CloudTreeStyle, icon: String, tint: Color, + iconAsset: String? = nil, title: String, titleWeight: Font.Weight = .regular, titleDimmed: Bool = false, @@ -286,6 +296,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { style: style, icon: icon, tint: tint, + iconAsset: iconAsset, title: title, titleWeight: titleWeight, titleDimmed: titleDimmed, @@ -296,9 +307,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { } } -/// A cmux-tui terminal row: lifecycle glyph, title (a dim sparkle prefix when an -/// agent is running in it), dimmed cwd, an optional daemon-tab badge on pool -/// rows, and a dim "open" mark when a local pane is already showing it. +/// A cmux-tui terminal row with its provider mark, title, directory and optional view count. struct CloudTreeTerminalRowContent: View { let row: CloudTreeTerminalRow var style: CloudTreeStyle = CloudTreeStyleStore.current @@ -323,6 +332,7 @@ struct CloudTreeTerminalRowContent: View { style: style, icon: glyph, tint: CloudTreeIconPalette.terminal, + iconAsset: terminal.terminalAgentIconAssetName, title: row.displayTitle.isEmpty ? String(localized: "cloudTree.terminal.untitled", defaultValue: "terminal") : row.displayTitle, titleDimmed: terminal.lifecycle == .exited || showsDetachedState, detail: terminal.detail.flatMap { $0.isEmpty ? nil : Self.abbreviated($0) } diff --git a/Sources/Cloud/CloudTreeRowIcon.swift b/Sources/Cloud/CloudTreeRowIcon.swift index b50085bbc439..fe258350c7e4 100644 --- a/Sources/Cloud/CloudTreeRowIcon.swift +++ b/Sources/Cloud/CloudTreeRowIcon.swift @@ -1,4 +1,6 @@ import CmuxFoundation +import AppKit +import CmuxAppKitSupportUI import SwiftUI /// A row glyph in the shared icon slot, drawn per the style's icon treatment: @@ -8,12 +10,30 @@ struct CloudTreeRowIcon: View { let style: CloudTreeStyle let systemName: String let tint: Color + var assetName: String? = nil var dimmed: Bool = false var weight: Font.Weight = .regular var size: CGFloat? = nil @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification var body: some View { + if let assetName { + CmuxResolvedIconImage(request: CmuxResolvedIconRequest( + source: .asset(name: assetName, bundle: .main), + size: NSSize(width: style.iconSize, height: style.iconSize), + fallbackSource: .systemSymbol(name: systemName, accessibilityDescription: nil), + fallbackTintColor: .secondaryLabelColor + )) + .frame(width: style.iconSlot, height: style.iconSize, alignment: .center) + .opacity(dimmed ? 0.45 : 1) + .accessibilityHidden(true) + } else { + systemIcon + } + } + + @ViewBuilder + private var systemIcon: some View { switch style.iconTreatment { case .monochrome: Image(systemName: systemName) diff --git a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift index 771eb9605878..9883b3fc9cc9 100644 --- a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift +++ b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift @@ -10,7 +10,7 @@ extension CmuxTaskManagerCodingAgentDefinition { .init(id: "codex", displayName: "Codex", assetName: "AgentIcons/Codex", launchKinds: ["codex", "omx"], directBasenames: ["codex", "omx"], argumentNeedles: ["codex", "@openai/codex", "oh-my-codex"]), - .init(id: "grok", displayName: "Grok", assetName: nil, + .init(id: "grok", displayName: "Grok", assetName: "AgentIcons/Grok", launchKinds: ["grok"], directBasenames: ["grok", "grok-macos-aarch64", "grok-macos-aarch"], argumentNeedles: ["grok", "grok-build", "@xai/grok"]), .init(id: "opencode", displayName: "OpenCode", assetName: "AgentIcons/OpenCode", @@ -26,11 +26,11 @@ extension CmuxTaskManagerCodingAgentDefinition { argumentNeedles: ["@mariozechner/pi-coding-agent", "pi-coding-agent"]), .init(id: "amp", displayName: "Amp", assetName: "AgentIcons/Amp", launchKinds: ["amp"], directBasenames: ["amp"], argumentNeedles: ["@ampcode"]), - .init(id: "cursor", displayName: "Cursor", assetName: nil, + .init(id: "cursor", displayName: "Cursor", assetName: "AgentIcons/Cursor", launchKinds: ["cursor"], directBasenames: ["cursor-agent"], argumentNeedles: ["cursor-agent"]), - .init(id: "gemini", displayName: "Gemini", assetName: nil, + .init(id: "gemini", displayName: "Gemini", assetName: "AgentIcons/Gemini", launchKinds: ["gemini"], directBasenames: ["gemini"], argumentNeedles: ["gemini"]), - .init(id: "kiro", displayName: "Kiro", assetName: nil, + .init(id: "kiro", displayName: "Kiro", assetName: "AgentIcons/Kiro", launchKinds: ["kiro"], directBasenames: ["kiro", "kiro-cli"], argumentNeedles: ["kiro", "kiro-cli"]), .init(id: "antigravity", displayName: "Antigravity", assetName: "AgentIcons/Antigravity", launchKinds: ["antigravity", "agy"], directBasenames: ["agy", "antigravity"], @@ -39,18 +39,18 @@ extension CmuxTaskManagerCodingAgentDefinition { launchKinds: ["rovodev", "rovo"], directBasenames: ["rovodev"], argumentNeedles: ["rovodev"]), .init(id: "hermes-agent", displayName: "Hermes Agent", assetName: "AgentIcons/HermesAgent", launchKinds: ["hermes-agent"], directBasenames: ["hermes", "hermes-agent"], argumentNeedles: ["hermes-agent"]), - .init(id: "copilot", displayName: "Copilot", assetName: nil, - launchKinds: ["copilot"], directBasenames: ["copilot"], argumentNeedles: ["copilot"]), - .init(id: "codebuddy", displayName: "CodeBuddy", assetName: nil, + .init(id: "copilot", displayName: "Copilot", assetName: "AgentIcons/Copilot", + launchKinds: ["copilot"], directBasenames: ["copilot", "github-copilot"], argumentNeedles: ["copilot"]), + .init(id: "codebuddy", displayName: "CodeBuddy", assetName: "AgentIcons/CodeBuddy", launchKinds: ["codebuddy"], directBasenames: ["codebuddy"], argumentNeedles: ["codebuddy"]), - .init(id: "factory", displayName: "Factory", assetName: nil, + .init(id: "factory", displayName: "Factory", assetName: "AgentIcons/Factory", launchKinds: ["factory"], directBasenames: ["droid", "factory"], argumentNeedles: ["factory"]), - .init(id: "qoder", displayName: "Qoder", assetName: nil, + .init(id: "qoder", displayName: "Qoder", assetName: "AgentIcons/Qoder", launchKinds: ["qoder"], directBasenames: ["qoder", "qodercli"], argumentNeedles: ["qoder", "qodercli"]), .init( id: "kimi", displayName: String(localized: "agent.kimi.displayName", defaultValue: "Kimi Code"), - assetName: nil, + assetName: "AgentIcons/Kimi", launchKinds: ["kimi"], // Kimi's Python entrypoint deliberately overwrites its OS process title and argv with // "Kimi Code". This is process-status/foreground detection only; session persistence @@ -61,7 +61,7 @@ extension CmuxTaskManagerCodingAgentDefinition { .init( id: "ollama", displayName: String(localized: "agent.ollama.displayName", defaultValue: "Ollama"), - assetName: nil, + assetName: "AgentIcons/Ollama", launchKinds: ["ollama"], directBasenames: ["ollama"], // No argument needles: a bare "ollama" token plus the "run" diff --git a/Sources/Surfaces/CmuxTuiSnapshotParser.swift b/Sources/Surfaces/CmuxTuiSnapshotParser.swift index 351aaaadc71c..e38a2b45770e 100644 --- a/Sources/Surfaces/CmuxTuiSnapshotParser.swift +++ b/Sources/Surfaces/CmuxTuiSnapshotParser.swift @@ -191,7 +191,15 @@ struct CmuxTuiSnapshotParser: Sendable { } let agents = ((snapshot["agents"] as? [[String: Any]]) ?? []).compactMap { raw -> CloudVMAgentState? in guard let terminalID = nonEmptyString(raw["terminal_id"]), let state = nonEmptyString(raw["state"]) else { return nil } - return CloudVMAgentState(id: nonEmptyString(raw["id"]), terminalID: terminalID, state: state, source: nonEmptyString(raw["source"])) + return CloudVMAgentState( + id: nonEmptyString(raw["id"]), + terminalID: terminalID, + state: state, + source: nonEmptyString(raw["source"]), + agent: nonEmptyString(raw["agent"]) + ?? nonEmptyString(raw["agent_type"]) + ?? nonEmptyString(raw["provider"]) + ) } return CloudVMState( @@ -536,7 +544,7 @@ struct CmuxTuiSnapshotParser: Sendable { lifecycle: SurfaceLifecycle(rawValue: terminal.lifecycle) ?? (terminal.running == true ? .running : .exited), agent: state.lookupIndex.agent(terminalID: terminal.id).map { - SurfaceAgentBadge(state: $0.state, source: $0.source) + SurfaceAgentBadge(state: $0.state, source: $0.source, agent: $0.agent) }, remoteWorkspace: nil, port: nil, @@ -1019,7 +1027,10 @@ struct CmuxTuiSnapshotParser: Sendable { id: nonEmptyString(value["id"]), terminalID: terminalID, state: state, - source: nonEmptyString(value["source"]) + source: nonEmptyString(value["source"]), + agent: nonEmptyString(value["agent"]) + ?? nonEmptyString(value["agent_type"]) + ?? nonEmptyString(value["provider"]) ) } @@ -1421,7 +1432,13 @@ struct CmuxTuiSnapshotParser: Sendable { var agentByTerminal: [String: SurfaceAgentBadge] = [:] for agent in agentsRaw { guard let terminalID = agent["terminal_id"] as? String, let state = agent["state"] as? String else { continue } - agentByTerminal[terminalID] = SurfaceAgentBadge(state: state, source: agent["source"] as? String) + agentByTerminal[terminalID] = SurfaceAgentBadge( + state: state, + source: agent["source"] as? String, + agent: (agent["agent"] as? String) + ?? (agent["agent_type"] as? String) + ?? (agent["provider"] as? String) + ) } let workspaces = Self.workspaces(fromSnapshot: snapshot) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 512701690b9f..be3e8068c7e2 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -86,6 +86,10 @@ extension CmuxTuiSurfaceProvider { ) else { throw CancellationError() } + workspace.updateCloudTerminalTabIcon( + panelID: adopted.panelID, + assetName: resource.terminalAgentIconAssetName + ) created = adopted reservation.inputRelay.attach(inputRouter) // The card's grace counts from the moment the pane appeared. @@ -94,6 +98,7 @@ extension CmuxTuiSurfaceProvider { created = try SurfacePaneFactory.makeCloudManualMirrorPane( at: destination, focus: focus, + iconAssetName: resource.terminalAgentIconAssetName, onInput: { input in inputRouter.send(input) }, keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value }, onResize: { [weak session] sample in diff --git a/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift new file mode 100644 index 000000000000..06042e101069 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift @@ -0,0 +1,24 @@ +import Foundation + +extension SurfaceCatalog { + /// Visits projections once per accepted catalog transaction, independent of + /// the number of resources in a full snapshot. Missing resources clear icons. + func syncCloudTerminalTabIcons(on machine: SurfaceMachineID, affected: Set? = nil) { + guard !machine.isLocal else { return } + for projection in projections where projection.resource.machine == machine { + if let affected, !affected.contains(projection.resource) { continue } + syncCloudTerminalTabIcon(projection) + } + } + + func syncCloudTerminalTabIcon(_ projection: SurfaceProjection) { + guard !projection.resource.machine.isLocal, projection.resource.kind == .terminal, + let workspace = cloudWorkspaceRenameService.environment.workspace(projection.workspaceID), + workspace.panels[projection.panelID] is TerminalPanel, + let tabID = workspace.surfaceIdFromPanelId(projection.panelID), + let tab = workspace.bonsplitController.tab(tabID) else { return } + let asset = resources[projection.resource]?.terminalAgentIconAssetName + guard tab.iconAsset != asset else { return } + workspace.bonsplitController.updateTab(tabID, iconAsset: .some(asset)) + } +} diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 0e4fba7e20e7..255ac517b403 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -193,6 +193,7 @@ final class SurfaceCatalog { machines[machine] = nil for id in resourceIDsByMachine[machine] ?? [] { resources[id] = nil } resourceIDsByMachine[machine] = nil + syncCloudTerminalTabIcons(on: machine) pendingRestoredProjections.remove(machine: machine) cloudWorkspaceProjectionCoordinator.cancel(machine: machine) cloudProjectionIndexDirty = true @@ -291,6 +292,7 @@ final class SurfaceCatalog { } if let info { machines[machine] = machineInfoPreservingCanonicalCloudState(info) } resolvePendingRestoredProjections(on: machine) + syncCloudTerminalTabIcons(on: machine) notifyChange() return true } @@ -302,6 +304,7 @@ final class SurfaceCatalog { resources[resource.id] = resource resourceIDsByMachine[resource.machine, default: []].insert(resource.id) resolvePendingRestoredProjections(on: resource.machine) + syncCloudTerminalTabIcons(on: resource.machine, affected: [resource.id]) notifyChange() } @@ -313,6 +316,7 @@ final class SurfaceCatalog { if resourceIDsByMachine[id.machine]?.isEmpty == true { resourceIDsByMachine[id.machine] = nil } + syncCloudTerminalTabIcons(on: id.machine, affected: [id]) notifyChange() } @@ -452,6 +456,7 @@ final class SurfaceCatalog { cloudStateObservations[state.machine] = observation machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state) resolvePendingRestoredProjections(on: state.machine) + syncCloudTerminalTabIcons(on: state.machine, affected: changed) notifyChange() return changed } @@ -489,6 +494,7 @@ final class SurfaceCatalog { cloudStateObservations[state.machine] = observation machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state) resolvePendingRestoredProjections(on: state.machine) + syncCloudTerminalTabIcons(on: state.machine, affected: changed) notifyChange() return changed } @@ -533,6 +539,7 @@ final class SurfaceCatalog { rebuildResourceIndex(for: machine) machines[machine] = machineInfoPreservingCanonicalCloudState(info) resolvePendingRestoredProjections(on: machine) + syncCloudTerminalTabIcons(on: machine) notifyChange() } @@ -1066,6 +1073,7 @@ final class SurfaceCatalog { insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.projectionInCurrentWorkspace(projection)) reconcileCloudWorkspaceBinding(localWorkspaceID: projection.workspaceID) reconcileCloudProjection(projection) + syncCloudTerminalTabIcon(projection) if let resource = resources[projection.resource], !resource.machine.isLocal, resource.kind == .terminal, let workspace = cloudWorkspaceRenameService.environment.workspace(projection.workspaceID) { diff --git a/Sources/Surfaces/SurfaceCatalogModel.swift b/Sources/Surfaces/SurfaceCatalogModel.swift index 28f4a563183d..b8d5e4a0d48a 100644 --- a/Sources/Surfaces/SurfaceCatalogModel.swift +++ b/Sources/Surfaces/SurfaceCatalogModel.swift @@ -96,6 +96,8 @@ enum SurfaceLifecycle: String, Codable, Sendable { struct SurfaceAgentBadge: Hashable, Codable, Sendable { var state: String var source: String? + /// The adapter identity, separate from report provenance (`hook`, `socket`, or `plugin`). + var agent: String? = nil } /// The daemon's monotonic position for one complete remote session state. @@ -289,6 +291,7 @@ struct CloudVMAgentState: Hashable, Codable, Sendable { var terminalID: String var state: String var source: String? + var agent: String? = nil } /// How a remote session can be synchronized. diff --git a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift index 9f7ea1e51c9b..8ce294a9c4e9 100644 --- a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift +++ b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift @@ -13,6 +13,7 @@ extension SurfacePaneFactory { static func makeCloudManualMirrorPane( at destination: SurfaceDestination, focus: Bool, + iconAssetName: String? = nil, onInput: @escaping @Sendable (TerminalManualInput) -> Void, keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, @@ -26,6 +27,7 @@ extension SurfacePaneFactory { return try workspace.addCloudManualMirrorPane( at: destination, focus: focus, + iconAssetName: iconAssetName, onInput: onInput, keyNameResolver: keyNameResolver, onResize: onResize, diff --git a/Sources/Surfaces/SurfaceResource+AgentIcon.swift b/Sources/Surfaces/SurfaceResource+AgentIcon.swift new file mode 100644 index 000000000000..87bb7d1a9e5d --- /dev/null +++ b/Sources/Surfaces/SurfaceResource+AgentIcon.swift @@ -0,0 +1,18 @@ +import Foundation + +extension SurfaceResource { + /// One provider identity drives the tab strip and every Cloud tree placement. + /// Report provenance and user-controlled terminal titles are not provider IDs. + var terminalAgentIconAssetName: String? { + guard kind == .terminal, lifecycle != .exited, + let badge = agent, badge.state != "done", + let identity = (badge.agent ?? badge.source)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !identity.isEmpty, + !["hook", "socket", "detected", "plugin", "unknown"].contains(identity) else { return nil } + return CmuxTaskManagerCodingAgentDefinition.builtIns.first { definition in + definition.id == identity + || definition.launchKinds.contains(identity) + || definition.directBasenames.contains(identity) + }?.assetName + } +} diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index 6ee8a773065b..a586aee05003 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -28,6 +28,7 @@ extension Workspace { func addCloudManualMirrorPane( at destination: SurfaceDestination, focus: Bool, + iconAssetName: String? = nil, onInput: @escaping @Sendable (TerminalManualInput) -> Void, keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, @@ -52,7 +53,9 @@ extension Workspace { onFocus: onFocus, attachment: attachment ) - let panelID = try workspace.insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: false) + let panelID = try workspace.insertCloudManualMirrorPanel( + panel, at: destination, focus: focus, isLoading: false, iconAssetName: iconAssetName + ) return (workspace.id, panelID, panel.surface) } @@ -81,7 +84,8 @@ extension Workspace { _ panel: TerminalPanel, at destination: SurfaceDestination, focus: Bool, - isLoading: Bool + isLoading: Bool, + iconAssetName: String? = nil ) throws -> UUID { switch destination { case .workspace(_, let placement): @@ -89,20 +93,20 @@ extension Workspace { guard let pane else { throw SurfaceCatalogError.destinationNotFound("focused pane") } switch placement { case .tab: - return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading) + return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) case .split: - return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading) + return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) } case .tab(_, let paneID, _): guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading) + return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) case .split(_, let paneID, let direction): guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading) + return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) } } @@ -110,13 +114,15 @@ extension Workspace { _ panel: TerminalPanel, in pane: PaneID, focus: Bool, - isLoading: Bool + isLoading: Bool, + iconAssetName: String? ) throws -> UUID { panels[panel.id] = panel panelTitles[panel.id] = panel.displayTitle guard let tab = bonsplitController.createTab( title: panel.displayTitle, icon: panel.displayIcon, + iconAsset: iconAssetName, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, isLoading: isLoading, @@ -143,7 +149,8 @@ extension Workspace { target: PaneID, direction: SurfaceSplitDirection, focus: Bool, - isLoading: Bool + isLoading: Bool, + iconAssetName: String? ) throws -> UUID { let previousPane = bonsplitController.focusedPaneId let previousTab = previousPane.flatMap { bonsplitController.selectedTab(inPane: $0)?.id } @@ -152,6 +159,7 @@ extension Workspace { let tab = Bonsplit.Tab( title: panel.displayTitle, icon: panel.displayIcon, + iconAsset: iconAssetName, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, isLoading: isLoading, @@ -192,6 +200,13 @@ extension Workspace { bonsplitController.updateTab(tabID, isLoading: isLoading) } + /// Updates a Cloud terminal tab after the daemon reports a provider identity change. + func updateCloudTerminalTabIcon(panelID: UUID, assetName: String?) { + guard let tabID = surfaceIdFromPanelId(panelID), + let tab = bonsplitController.tab(tabID), tab.iconAsset != assetName else { return } + bonsplitController.updateTab(tabID, iconAsset: .some(assetName)) + } + /// The live workspace with `id` in any window, or nil once it was retired. static func liveWorkspace(id: UUID) -> Workspace? { AppDelegate.shared?.tabManagerFor(tabId: id)?.tabs.first { $0.id == id } diff --git a/Sources/TerminalTabAgentIcon.swift b/Sources/TerminalTabAgentIcon.swift new file mode 100644 index 000000000000..79b6ae262033 --- /dev/null +++ b/Sources/TerminalTabAgentIcon.swift @@ -0,0 +1,52 @@ +import Bonsplit +import Foundation + +/// Resolves the provider mark for a local terminal tab from the same agent +/// definitions used by process and hook detection. +struct TerminalTabAgentIconResolver { + func assetName(forStatusKey statusKey: String) -> String? { + let normalized = statusKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return nil } + return CmuxTaskManagerCodingAgentDefinition.builtIns.first { definition in + definition.id == normalized + || definition.launchKinds.contains(normalized) + || definition.directBasenames.contains(normalized) + }?.assetName + } + + func titleStatusKey(from title: String) -> String? { + let token = title.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)?.lowercased() + guard let token else { return nil } + return assetName(forStatusKey: token) == nil ? nil : token + } +} + +extension Workspace { + /// Returns the current provider mark for one terminal panel, if known. + func terminalTabAgentIconAsset(forPanelId panelId: UUID) -> String? { + let resolver = TerminalTabAgentIconResolver() + let statusKeys = agentPIDKeysByPanelId[panelId, default: []] + .map(agentStatusKey(forAgentPIDKey:)) + .sorted() + if let asset = statusKeys.compactMap(resolver.assetName(forStatusKey:)).first { + return asset + } + if let title = panels[panelId]?.displayTitle, + let statusKey = resolver.titleStatusKey(from: title), + let asset = resolver.assetName(forStatusKey: statusKey) { + return asset + } + guard let restored = restoredAgentSnapshotsByPanelId[panelId] else { return nil } + return restored.registration?.iconAssetName ?? resolver.assetName(forStatusKey: restored.kind.rawValue) + } + + /// Reconciles a terminal tab's provider mark after agent lifecycle state changes. + 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) + guard tab.iconAsset != asset else { return } + bonsplitController.updateTab(tabID, iconAsset: .some(asset)) + } +} diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 37e1516a071c..cc439e477223 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -196,6 +196,9 @@ extension Workspace { } } if refreshPorts { refreshTrackedAgentPorts() } + for changedPanelID in Set([previous.panelId, panelId].compactMap { $0 }) { + syncTerminalTabAgentIconAsset(forPanelId: changedPanelID) + } return didClearOtherStructuredAgentRuntime } @@ -342,12 +345,16 @@ extension Workspace { if didChange, refreshPorts { refreshTrackedAgentPorts() } + if didChange { + syncTerminalTabAgentIconAsset(forPanelId: ownedPanelId ?? panelId) + } return didChange } /// Clears a panel's restored agent snapshot and resume metadata. func clearRestoredAgentSnapshot(panelId: UUID) { restoredAgentLifecycle.clearSessionRestore(panelId: panelId) + syncTerminalTabAgentIconAsset(forPanelId: panelId) } func refreshTrackedAgentPorts() { diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index 95561dfe8c49..be70b6d0525a 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -152,6 +152,10 @@ extension Workspace { } } + if !isRemoteTmuxMirror { + syncTerminalTabAgentIconAsset(forPanelId: panelId) + } + let previousWorkspaceTitle = self.title if applyFocusedPanelTitle(panelId: panelId) { didMutate = true diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index d91a8c81f4e8..fffba8d4e13d 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,6 +4,18 @@ cmux includes the following third-party software: --- +## Lobe Icons (selected agent marks) + +- **License:** MIT License +- **Copyright:** Copyright (c) 2023 LobeHub +- **Source:** https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons + +Selected Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi, and +Ollama SVG marks are bundled under `Assets.xcassets/AgentIcons`. The complete +license text is in `Assets.xcassets/AgentIcons/LOBE-LICENSE.txt`. + +--- + ## Ghostty - **License:** MIT License diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c5376f05448e..ac450f09ef7a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3079,6 +3079,7 @@ C0DE70530000000000000002 /* submit-cmux-profile in Copy CLI */ = {isa = PBXBuildFile; fileRef = C0DE70530000000000000001 /* submit-cmux-profile */; }; D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; C935C833B18F4CC18711BD77 /* SurfaceAgentNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */; }; + FB3A15A096C8270E929D34B6 /* SurfaceCatalog+AgentIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */; }; F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */; }; @@ -3111,6 +3112,7 @@ 7867A0010000000000000001 /* SurfaceProjectionRestoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7867A0020000000000000001 /* SurfaceProjectionRestoreStore.swift */; }; EE1DE3F7E67DF1366F214AC7 /* SurfaceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAED489C1C23533995AF55EA /* SurfaceProvider.swift */; }; 58FE3B39F41CF68A632CA083 /* SurfaceRemotePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */; }; + 07AFFFB52EA184A2DE379926 /* SurfaceResource+AgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */; }; 733F8CFB9036496EB237A178 /* SurfaceResource+CatalogOrdering.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6ACCF8253744008F186C73 /* SurfaceResource+CatalogOrdering.swift */; }; 91007F48E782474596D8FB7D /* SurfaceResource+CloudTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */; }; F96CAE8A14774122A5F8613B /* SurfaceResourceDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */; }; @@ -3454,6 +3456,7 @@ D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */; }; D3052001000000000000001 /* TerminalSurfaceResizePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */; }; C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */; }; + A6A6A6A60000000000000001 /* TerminalTabAgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */; }; A91C0D0F0000000000000002 /* TerminalTabIconRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */; }; F83620020000000000000001 /* TerminalTTYSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83620020000000000000002 /* TerminalTTYSessionIdentity.swift */; }; EC010A01 /* TerminalUploadCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010A02 /* TerminalUploadCommand.swift */; }; @@ -6916,6 +6919,7 @@ C0DE70530000000000000001 /* submit-cmux-profile */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/submit-cmux-profile"; sourceTree = SOURCE_ROOT; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceAgentNaming.swift"; sourceTree = ""; }; + ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+AgentIcons.swift"; sourceTree = ""; }; F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudBinding.swift"; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudRenameSubmission.swift"; sourceTree = ""; }; @@ -6948,6 +6952,7 @@ 7867A0020000000000000001 /* SurfaceProjectionRestoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProjectionRestoreStore.swift; sourceTree = ""; }; AAED489C1C23533995AF55EA /* SurfaceProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceProvider.swift; sourceTree = ""; }; CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceRemotePlacement.swift"; sourceTree = ""; }; + 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResource+AgentIcon.swift"; sourceTree = ""; }; FC6ACCF8253744008F186C73 /* SurfaceResource+CatalogOrdering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/SurfaceResource+CatalogOrdering.swift"; sourceTree = ""; }; A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResource+CloudTitle.swift"; sourceTree = ""; }; 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceResourceDragPayload.swift; sourceTree = ""; }; @@ -7290,6 +7295,7 @@ D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/TerminalSurface+RendererRealizationSurface.swift"; sourceTree = ""; }; D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceResizePolicyTests.swift; sourceTree = ""; }; C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceRuntimeWiring.swift; sourceTree = ""; }; + A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabAgentIcon.swift; sourceTree = ""; }; A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabIconRegressionTests.swift; sourceTree = ""; }; F83620020000000000000002 /* TerminalTTYSessionIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTTYSessionIdentity.swift; sourceTree = ""; }; EC010A02 /* TerminalUploadCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalUploadCommand.swift; sourceTree = ""; }; @@ -8155,7 +8161,9 @@ DD9E59E46A6A91958EE22451 /* CloudEnvDelivery.swift */, A79DE8A46550805CDAB18D09 /* SurfacePaneFactory+ProjectionLayout.swift */, F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */, + 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */, A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */, + 1A5D0FC63C854C90B2974F39 /* CloudWorkspaceProjectionTask.swift */, 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */, 2C422AAD63384578846BC05E /* TerminalController+CloudTerminalCreation.swift */, @@ -8178,7 +8186,9 @@ 0DFE35D27805468DBA885560 /* CloudWorkspaceProjectionEnvironment.swift */, A5A9286A232F4C3FAD571015 /* CloudWorkspaceProjectionCoordinator.swift */, 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */, + ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */, AB74882A29884E378E36D645 /* SurfaceCatalog+CloudWorkspaceProjection.swift */, + 183FE43D3F71A474726D6AA6 /* CloudWorkspaceLayoutTranslator.swift */, 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */, D12439050000000000000002 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift */, @@ -9756,6 +9766,7 @@ A5001099 /* TerminalNotificationScrollPosition.swift */, 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */, 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */, + A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */, D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */, A5001097 /* TerminalNotificationPolicy.swift */, F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */, @@ -12352,6 +12363,8 @@ + + A11CE0040000000000000001 /* AboutLicenseContent.swift in Sources */, A5C017000000000000000001 /* AccountSignInFlow.swift in Sources */, A5C017000000000000000003 /* AccountSignInModel.swift in Sources */, @@ -14203,6 +14216,7 @@ D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, C935C833B18F4CC18711BD77 /* SurfaceAgentNaming.swift in Sources */, + FB3A15A096C8270E929D34B6 /* SurfaceCatalog+AgentIcons.swift in Sources */, F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */, @@ -14232,6 +14246,7 @@ 7867A0010000000000000001 /* SurfaceProjectionRestoreStore.swift in Sources */, EE1DE3F7E67DF1366F214AC7 /* SurfaceProvider.swift in Sources */, 58FE3B39F41CF68A632CA083 /* SurfaceRemotePlacement.swift in Sources */, + 07AFFFB52EA184A2DE379926 /* SurfaceResource+AgentIcon.swift in Sources */, 733F8CFB9036496EB237A178 /* SurfaceResource+CatalogOrdering.swift in Sources */, 91007F48E782474596D8FB7D /* SurfaceResource+CloudTitle.swift in Sources */, F96CAE8A14774122A5F8613B /* SurfaceResourceDragPayload.swift in Sources */, @@ -14514,6 +14529,7 @@ 12476000000000000000000A /* TerminalSurface+ImageTransferTarget.swift in Sources */, D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */, C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */, + A6A6A6A60000000000000001 /* TerminalTabAgentIcon.swift in Sources */, F83620020000000000000001 /* TerminalTTYSessionIdentity.swift in Sources */, EC010A01 /* TerminalUploadCommand.swift in Sources */, 5154BEAB50364B86A9E36E4B /* TerminalViewportUITestRecorder.swift in Sources */, diff --git a/cmuxTests/CloudSidebarConsistencyTests.swift b/cmuxTests/CloudSidebarConsistencyTests.swift index 2ce00b774346..f1b27f14fafa 100644 --- a/cmuxTests/CloudSidebarConsistencyTests.swift +++ b/cmuxTests/CloudSidebarConsistencyTests.swift @@ -226,6 +226,26 @@ struct CloudSidebarConsistencyTests { #expect(workspace.panelCustomTitleSources[panelID] == .user) } + @Test("Supported Cloud providers resolve their bundled marks") + func supportedCloudProviderMarks() { + let expected = [ + "claude": "AgentIcons/Claude", "codex": "AgentIcons/Codex", + "opencode": "AgentIcons/OpenCode", "pi": "AgentIcons/Pi", + "amp": "AgentIcons/Amp", "cursor": "AgentIcons/Cursor", + "gemini": "AgentIcons/Gemini", "kiro": "AgentIcons/Kiro", + "copilot": "AgentIcons/Copilot", "codebuddy": "AgentIcons/CodeBuddy", + "factory": "AgentIcons/Factory", "qoder": "AgentIcons/Qoder", + "kimi": "AgentIcons/Kimi", "ollama": "AgentIcons/Ollama" + ] + for (provider, asset) in expected { + let badge = SurfaceAgentBadge(state: "working", source: "hook", agent: provider) + #expect(badge.agent == provider) + #expect(CmuxTaskManagerCodingAgentDefinition.builtIns.first(where: { $0.id == provider })?.assetName == asset) + } + #expect(TerminalTabAgentIconResolver().assetName(forStatusKey: "codex") == "AgentIcons/Codex") + #expect(TerminalTabAgentIconResolver().assetName(forStatusKey: "gemini") == "AgentIcons/Gemini") + } + @Test("A bound native tab receives canonical names, process titles, and ignores delayed graph callbacks", arguments: [false, true]) func nativeNameParity(named: Bool) throws { let manager = TabManager() diff --git a/cmuxTests/CmuxTuiSurfaceProviderTests.swift b/cmuxTests/CmuxTuiSurfaceProviderTests.swift index f659c49174a1..e646214281b1 100644 --- a/cmuxTests/CmuxTuiSurfaceProviderTests.swift +++ b/cmuxTests/CmuxTuiSurfaceProviderTests.swift @@ -134,6 +134,7 @@ import Testing #expect(build.detail == "/root/work/app") #expect(build.lifecycle == .running) #expect(build.agent == SurfaceAgentBadge(state: "working", source: "claude")) + #expect(build.terminalAgentIconAssetName == "AgentIcons/Claude") #expect(build.remoteWorkspace == SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true)) #expect(build.remoteViews?.map(\.tabID) == ["tab_1", "tab_4"]) #expect(build.remoteWorkspaces.map(\.id) == ["ws_main", "ws_api"]) @@ -156,6 +157,15 @@ import Testing #expect(detached.lifecycle == .running) } + @Test func providerAwareAgentFieldResolvesCodexMark() throws { + var snapshot = Self.sessionSnapshot + snapshot["agents"] = [["id": "agent_1", "terminal_id": "term_build", "state": "working", "source": "hook", "agent": "codex"]] + let resources = CmuxTuiSnapshotParser.terminals(fromSnapshot: snapshot, machine: Self.machine) + let terminal = try #require(resources.first { $0.id.key == "term_build" }) + #expect(terminal.agent?.agent == "codex") + #expect(terminal.terminalAgentIconAssetName == "AgentIcons/Codex") + } + @Test func userTabNameStaysOnTheIndividualRemoteView() throws { var snapshot = Self.sessionSnapshot snapshot["tabs"] = [ From de71da70c2df8421c85a1ba07640ed588158df43 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:59:01 -0700 Subject: [PATCH 2/9] feat(tui): detect agent providers for Cloud branding --- cmux-tui/crates/cmux-tui-core/Cargo.toml | 1 + cmux-tui/crates/cmux-tui-core/src/lib.rs | 1 + cmux-tui/crates/cmux-tui-core/src/mux.rs | 49 + .../crates/cmux-tui-core/src/resource_api.rs | 9 +- .../src/screen_detect/manifest.rs | 953 ++++++++++++++++++ .../cmux-tui-core/src/screen_detect/mod.rs | 391 +++++++ .../src/screen_detect/scanner.rs | 122 +++ cmux-tui/crates/cmux-tui-core/src/server.rs | 1 + cmux-tui/vendor/herdr-manifests/LICENSE | 201 ++++ cmux-tui/vendor/herdr-manifests/README.md | 6 + cmux-tui/vendor/herdr-manifests/amp.toml | 65 ++ .../vendor/herdr-manifests/antigravity.toml | 33 + cmux-tui/vendor/herdr-manifests/claude.toml | 207 ++++ cmux-tui/vendor/herdr-manifests/cline.toml | 26 + cmux-tui/vendor/herdr-manifests/codex.toml | 89 ++ cmux-tui/vendor/herdr-manifests/cursor.toml | 57 ++ cmux-tui/vendor/herdr-manifests/devin.toml | 86 ++ cmux-tui/vendor/herdr-manifests/droid.toml | 48 + cmux-tui/vendor/herdr-manifests/gemini.toml | 25 + .../herdr-manifests/github-copilot.toml | 37 + cmux-tui/vendor/herdr-manifests/grok.toml | 164 +++ cmux-tui/vendor/herdr-manifests/hermes.toml | 102 ++ cmux-tui/vendor/herdr-manifests/kilo.toml | 24 + cmux-tui/vendor/herdr-manifests/kimi.toml | 77 ++ cmux-tui/vendor/herdr-manifests/kiro.toml | 63 ++ cmux-tui/vendor/herdr-manifests/maki.toml | 68 ++ cmux-tui/vendor/herdr-manifests/muse.toml | 113 +++ cmux-tui/vendor/herdr-manifests/opencode.toml | 37 + cmux-tui/vendor/herdr-manifests/pi.toml | 13 + cmux-tui/vendor/herdr-manifests/qodercli.toml | 38 + cmux-tui/vendor/herdr-manifests/qwen.toml | 119 +++ 31 files changed, 3224 insertions(+), 1 deletion(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs create mode 100644 cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs create mode 100644 cmux-tui/crates/cmux-tui-core/src/screen_detect/scanner.rs create mode 100644 cmux-tui/vendor/herdr-manifests/LICENSE create mode 100644 cmux-tui/vendor/herdr-manifests/README.md create mode 100644 cmux-tui/vendor/herdr-manifests/amp.toml create mode 100644 cmux-tui/vendor/herdr-manifests/antigravity.toml create mode 100644 cmux-tui/vendor/herdr-manifests/claude.toml create mode 100644 cmux-tui/vendor/herdr-manifests/cline.toml create mode 100644 cmux-tui/vendor/herdr-manifests/codex.toml create mode 100644 cmux-tui/vendor/herdr-manifests/cursor.toml create mode 100644 cmux-tui/vendor/herdr-manifests/devin.toml create mode 100644 cmux-tui/vendor/herdr-manifests/droid.toml create mode 100644 cmux-tui/vendor/herdr-manifests/gemini.toml create mode 100644 cmux-tui/vendor/herdr-manifests/github-copilot.toml create mode 100644 cmux-tui/vendor/herdr-manifests/grok.toml create mode 100644 cmux-tui/vendor/herdr-manifests/hermes.toml create mode 100644 cmux-tui/vendor/herdr-manifests/kilo.toml create mode 100644 cmux-tui/vendor/herdr-manifests/kimi.toml create mode 100644 cmux-tui/vendor/herdr-manifests/kiro.toml create mode 100644 cmux-tui/vendor/herdr-manifests/maki.toml create mode 100644 cmux-tui/vendor/herdr-manifests/muse.toml create mode 100644 cmux-tui/vendor/herdr-manifests/opencode.toml create mode 100644 cmux-tui/vendor/herdr-manifests/pi.toml create mode 100644 cmux-tui/vendor/herdr-manifests/qodercli.toml create mode 100644 cmux-tui/vendor/herdr-manifests/qwen.toml diff --git a/cmux-tui/crates/cmux-tui-core/Cargo.toml b/cmux-tui/crates/cmux-tui-core/Cargo.toml index ac5bcc24fb3b..7d3588259573 100644 --- a/cmux-tui/crates/cmux-tui-core/Cargo.toml +++ b/cmux-tui/crates/cmux-tui-core/Cargo.toml @@ -31,6 +31,7 @@ fs4.workspace = true sha2.workspace = true zeroize.workspace = true url.workspace = true +toml.workspace = true [target.'cfg(windows)'.dependencies] uds_windows.workspace = true diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index aade7f08b183..4a25768e0308 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod resource_name; mod resource_router; mod resource_selector; mod resource_tab; +mod screen_detect; mod short_id; mod sidebar_resource; mod surface; diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 56e603244a77..611cb95c0375 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -1237,6 +1237,7 @@ pub struct AgentRecord { pub state: AgentState, pub source: AgentSource, pub session: Option, + pub agent: Option, pub updated_at_ms: u64, } @@ -2301,6 +2302,10 @@ pub struct Mux { sidebar_plugin: Mutex, machine_usage: Mutex>, agent_records: Mutex>, + /// Ephemeral provider identity from foreground-process detection. The + /// durable state remains the generic `detected` source; a fresh scanner + /// observation repopulates this map after restart. + detected_agent_by_terminal: Mutex>, agent_hook_fences: Mutex>, /// Nonterminal notifications remain placement-local. Terminal unread /// state is keyed separately by stable content identity so every view of @@ -2720,6 +2725,7 @@ impl Mux { sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), machine_usage: Mutex::new(None), agent_records: Mutex::new(agent_records), + detected_agent_by_terminal: Mutex::new(HashMap::new()), agent_hook_fences: Mutex::new(agent_hook_fences), placement_notifications: Mutex::new(HashMap::new()), terminal_notifications: Mutex::new(terminal_notifications), @@ -2790,6 +2796,7 @@ impl Mux { } mux.retry_pending_agent_hooks()?; crate::journal_hooks::start(&mux)?; + crate::screen_detect::scanner::start(&mux); Ok(mux) } @@ -5892,6 +5899,45 @@ impl Mux { self.workspace_registry.lock().unwrap().journal_hook_states() } + /// Snapshot live terminals for the screen detector. + pub(crate) fn screen_detect_terminals(&self) -> Vec<(TerminalPublicId, Arc)> { + self.state + .lock() + .unwrap() + .terminal_catalog + .iter() + .map(|(terminal_id, surface)| (terminal_id.clone(), surface.clone())) + .collect() + } + + /// Returns the ephemeral provider identity detected for one terminal. + pub(crate) fn detected_agent_for_terminal(&self, terminal_id: &TerminalPublicId) -> Option { + self.detected_agent_by_terminal.lock().unwrap().get(terminal_id).cloned() + } + + /// Records one screen-detected transition through the existing agent + /// projection path. The provider identity is kept beside the generic + /// source so the public snapshot can brand the terminal. + pub(crate) fn append_screen_detect_event( + &self, + emission: &crate::screen_detect::ScreenDetectEmission, + ) { + let Ok(terminal_id) = TerminalPublicId::parse(&emission.terminal_id) else { return }; + let Some(surface) = self.resource_surface_for_terminal(&terminal_id) else { return }; + let Ok(record) = self.report_agent(surface, emission.state, AgentSource::Detected, None) else { + return; + }; + if emission.state == AgentState::Done { + self.detected_agent_by_terminal.lock().unwrap().remove(&record.terminal_id); + } else { + self.detected_agent_by_terminal + .lock() + .unwrap() + .insert(record.terminal_id, emission.agent.clone()); + } + self.publish_resource_event(); + } + pub(crate) fn journal_events_caused_by_hooks( &self, hook_ids: &[String], @@ -9980,6 +10026,7 @@ impl Mux { state: record.state, source: record.source, session: record.session, + agent: None, updated_at_ms: record.updated_at_ms, }; if !commit.replayed { @@ -10021,6 +10068,7 @@ impl Mux { } // The registry guard is dropped before acquiring the fence guard. self.agent_hook_fences.lock().unwrap().remove(terminal_id); + self.detected_agent_by_terminal.lock().unwrap().remove(terminal_id); self.agent_records.lock().unwrap().remove(terminal_id); self.terminal_notifications.lock().unwrap().remove(terminal_id); } @@ -10076,6 +10124,7 @@ impl Mux { state: record.state, source: record.source, session: record.session, + agent: self.detected_agent_for_terminal(&terminal_id), updated_at_ms: record.updated_at_ms, }) }) diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs index a78b1b3e75d0..49c244b648df 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs @@ -733,7 +733,14 @@ pub(crate) fn public_session_snapshot_with_journal_head( } agent }) - .map(|agent| agent.into_public_snapshot(&topology.session_id)) + .map(|agent| { + let detected = mux.detected_agent_for_terminal(&agent.terminal_id); + let mut snapshot = agent.into_public_snapshot(&topology.session_id); + if let Some(detected) = detected { + snapshot["agent"] = json!(detected); + } + snapshot + }) .collect::>(); agents.sort_by(|left, right| { left["id"].as_str().unwrap_or_default().cmp(right["id"].as_str().unwrap_or_default()) diff --git a/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs b/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs new file mode 100644 index 000000000000..89405dd9abc9 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs @@ -0,0 +1,953 @@ +//! Screen-detection manifest engine. +//! +//! Ported from herdrdev/herdr `src/detect/manifest.rs` at commit +//! 7b675f42af35508eab66ac42fe1598628597a893 (Apache-2.0, see +//! `vendor/herdr-manifests/LICENSE`), modified by manaflow: bundled +//! manifests only (no remote updates, no local overrides), agents are +//! identified by manifest id/alias strings instead of a closed enum, and +//! the explain machinery is trimmed to what the detector consumes. + +use std::sync::OnceLock; + +use regex::Regex; +use serde::Deserialize; + +/// Highest herdr manifest engine version whose semantics this port covers. +pub(crate) const SCREEN_DETECT_ENGINE_VERSION: u32 = 3; + +const MAX_RULES_PER_MANIFEST: usize = 128; +const MAX_GATE_DEPTH: usize = 8; +const MAX_TOTAL_GATES: usize = 512; +const MAX_MATCHERS_PER_GATE: usize = 32; +const MAX_TOTAL_MATCHERS: usize = 1024; +const MAX_MATCHER_CHARS: usize = 512; + +/// Detection states a manifest rule can assign to a screen snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScreenState { + Idle, + Working, + Blocked, + Unknown, +} + +/// Screen snapshot plus OSC-derived strings. Empty `osc_title` / +/// `osc_progress` behave exactly like the pre-OSC herdr engine. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DetectionInput<'a> { + pub(crate) screen: &'a str, + pub(crate) osc_title: &'a str, + pub(crate) osc_progress: &'a str, +} + +/// What one manifest evaluation concluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Detection { + pub(crate) state: ScreenState, + /// The screen shows an agent-owned viewer (transcript scroll etc.); + /// the previous state must be kept. + pub(crate) skip_state_update: bool, + /// Matched rule id, absent when the known-agent idle fallback applied. + pub(crate) matched_rule: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentManifest { + id: String, + #[serde(rename = "version")] + _version: Option, + min_engine_version: Option, + #[serde(rename = "updated_at")] + _updated_at: Option, + #[serde(default)] + aliases: Vec, + #[serde(default)] + rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestRule { + id: String, + state: Option, + #[serde(default)] + priority: i32, + #[serde(default = "default_region")] + region: String, + #[serde(default)] + visible_idle: bool, + #[serde(default)] + visible_blocker: bool, + #[serde(default)] + visible_working: bool, + #[serde(default)] + skip_state_update: bool, + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestGate { + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ManifestState { + Idle, + Working, + Blocked, + Unknown, +} + +impl From for ScreenState { + fn from(value: ManifestState) -> Self { + match value { + ManifestState::Idle => ScreenState::Idle, + ManifestState::Working => ScreenState::Working, + ManifestState::Blocked => ScreenState::Blocked, + ManifestState::Unknown => ScreenState::Unknown, + } + } +} + +fn default_region() -> String { + "whole_recent".to_string() +} + +#[derive(Debug, Clone)] +struct CompiledGate { + all: Vec, + any: Vec, + not_gate: Vec, + contains: Vec, + regex: Vec, + line_regex: Vec, +} + +/// One agent manifest with its rule gates compiled to regex matchers. +#[derive(Debug, Clone)] +pub(crate) struct CompiledManifest { + manifest: AgentManifest, + compiled_rules: Vec, +} + +impl CompiledManifest { + pub(crate) fn id(&self) -> &str { + &self.manifest.id + } + + /// True when a process name equals the manifest id or one of its + /// aliases after path/basename and extension normalization. + pub(crate) fn matches_process_name(&self, process_name: &str) -> bool { + let name = normalized_agent_lookup_name(path_basename(process_name)); + name == self.manifest.id + || self.manifest.aliases.iter().any(|alias| normalized_agent_lookup_name(alias) == name) + } + + /// Evaluate every rule against the snapshot; the highest-priority match + /// wins (first rule wins a priority tie). No match falls back to `Idle`: + /// a known agent showing none of its working/blocked chrome is at rest + /// (herdr's `default_known_agent_idle_fallback`). + pub(crate) fn detect(&self, input: DetectionInput<'_>) -> Detection { + let mut matched: Option<&ManifestRule> = None; + for (rule, compiled) in self.manifest.rules.iter().zip(&self.compiled_rules) { + let region_text = region(input, &rule.region); + if !compiled_gate_matches_text(compiled, region_text) { + continue; + } + match matched { + Some(previous) if previous.priority >= rule.priority => {} + _ => matched = Some(rule), + } + } + let Some(rule) = matched else { + return Detection { + state: ScreenState::Idle, + skip_state_update: false, + matched_rule: None, + }; + }; + Detection { + state: rule.state.map(ScreenState::from).unwrap_or(ScreenState::Unknown), + skip_state_update: rule.skip_state_update, + matched_rule: Some(rule.id.clone()), + } + } +} + +fn compiled_gate_matches_text(gate: &CompiledGate, text: &str) -> bool { + let lower_text = text.to_lowercase(); + compiled_gate_matches(gate, text, &lower_text) +} + +fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool { + if !gate.contains.iter().all(|needle| lower_text.contains(needle)) { + return false; + } + if !gate.regex.iter().all(|regex| regex.is_match(text)) { + return false; + } + if !gate.line_regex.iter().all(|regex| text.lines().any(|line| regex.is_match(line))) { + return false; + } + if !gate.all.iter().all(|nested| compiled_gate_matches(nested, text, lower_text)) { + return false; + } + if !gate.any.is_empty() + && !gate.any.iter().any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + if gate.not_gate.iter().any(|nested| compiled_gate_matches(nested, text, lower_text)) { + return false; + } + true +} + +fn normalized_agent_lookup_name(name: &str) -> String { + let mut name = name.trim().to_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] { + if name.ends_with(suffix) { + name.truncate(name.len() - suffix.len()); + break; + } + } + name +} + +fn path_basename(path: &str) -> &str { + path.rsplit(['/', '\\']).find(|component| !component.is_empty()).unwrap_or(path) +} + +/// Every bundled manifest, keyed for foreground-process identification. +#[derive(Debug)] +pub(crate) struct ManifestSet { + manifests: Vec, +} + +/// The vendored herdr manifests (see `vendor/herdr-manifests/README.md` +/// for the upstream pin). Compile-time embedded; never fetched. +const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ + ("amp", include_str!("../../../../vendor/herdr-manifests/amp.toml")), + ("agy", include_str!("../../../../vendor/herdr-manifests/antigravity.toml")), + ("claude", include_str!("../../../../vendor/herdr-manifests/claude.toml")), + ("cline", include_str!("../../../../vendor/herdr-manifests/cline.toml")), + ("codex", include_str!("../../../../vendor/herdr-manifests/codex.toml")), + ("cursor", include_str!("../../../../vendor/herdr-manifests/cursor.toml")), + ("devin", include_str!("../../../../vendor/herdr-manifests/devin.toml")), + ("droid", include_str!("../../../../vendor/herdr-manifests/droid.toml")), + ("gemini", include_str!("../../../../vendor/herdr-manifests/gemini.toml")), + ("grok", include_str!("../../../../vendor/herdr-manifests/grok.toml")), + ("hermes", include_str!("../../../../vendor/herdr-manifests/hermes.toml")), + ("kilo", include_str!("../../../../vendor/herdr-manifests/kilo.toml")), + ("kimi", include_str!("../../../../vendor/herdr-manifests/kimi.toml")), + ("kiro", include_str!("../../../../vendor/herdr-manifests/kiro.toml")), + ("maki", include_str!("../../../../vendor/herdr-manifests/maki.toml")), + ("muse", include_str!("../../../../vendor/herdr-manifests/muse.toml")), + ("opencode", include_str!("../../../../vendor/herdr-manifests/opencode.toml")), + ("pi", include_str!("../../../../vendor/herdr-manifests/pi.toml")), + ("qodercli", include_str!("../../../../vendor/herdr-manifests/qodercli.toml")), + ("qwen", include_str!("../../../../vendor/herdr-manifests/qwen.toml")), + ("copilot", include_str!("../../../../vendor/herdr-manifests/github-copilot.toml")), +]; + +static BUNDLED_SET: OnceLock = OnceLock::new(); + +impl ManifestSet { + /// The embedded manifest set. Bundled files are pinned by unit tests, + /// so a compile failure here is a vendoring bug, not a runtime input. + pub(crate) fn bundled() -> &'static ManifestSet { + BUNDLED_SET.get_or_init(|| { + Self::from_sources(BUNDLED_MANIFESTS) + .expect("bundled screen-detection manifests are pinned valid by tests") + }) + } + + pub(crate) fn from_sources(sources: &[(&str, &str)]) -> Result { + let manifests = sources + .iter() + .map(|(label, content)| { + compile_manifest_source(content) + .map_err(|err| format!("bundled manifest {label} is invalid: {err}")) + }) + .collect::, _>>()?; + Ok(Self { manifests }) + } + + pub(crate) fn manifests(&self) -> impl Iterator { + self.manifests.iter() + } + + /// The manifest whose id or aliases match the foreground process name, + /// or `None` when the process is not a supported agent. + pub(crate) fn identify(&self, process_name: &str) -> Option<&CompiledManifest> { + self.manifests.iter().find(|manifest| manifest.matches_process_name(process_name)) + } +} + +pub(crate) fn compile_manifest_source(content: &str) -> Result { + let manifest = toml::from_str::(content).map_err(|err| err.to_string())?; + validate_manifest(&manifest)?; + let compiled_rules = compile_manifest(&manifest)?; + Ok(CompiledManifest { manifest, compiled_rules }) +} + +fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { + if manifest.id.trim().is_empty() { + return Err("manifest id must not be empty".to_string()); + } + if let Some(version) = manifest.min_engine_version + && version > SCREEN_DETECT_ENGINE_VERSION + { + return Err(format!( + "manifest requires engine {version}, this engine is {SCREEN_DETECT_ENGINE_VERSION}" + )); + } + if manifest.rules.is_empty() { + return Err("manifest must contain at least one rule".to_string()); + } + if manifest.rules.len() > MAX_RULES_PER_MANIFEST { + return Err(format!( + "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}", + manifest.rules.len() + )); + } + + let mut complexity = ManifestComplexity::default(); + for rule in &manifest.rules { + if rule.id.trim().is_empty() { + return Err("manifest rule id must not be empty".to_string()); + } + if rule.skip_state_update { + if rule.state != Some(ManifestState::Unknown) { + return Err(format!( + "rule {} uses skip_state_update without state = \"unknown\"", + rule.id + )); + } + if rule.visible_idle || rule.visible_blocker || rule.visible_working { + return Err(format!( + "rule {} uses skip_state_update with visible state evidence", + rule.id + )); + } + } + validate_region_name(&rule.region) + .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?; + validate_gate(&manifest_gate_from_rule(rule), "rule", 0, &mut complexity) + .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?; + } + Ok(()) +} + +#[derive(Default)] +struct ManifestComplexity { + total_gates: usize, + total_matchers: usize, +} + +fn validate_gate( + gate: &ManifestGate, + context: &str, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, context, complexity)?; + if !gate_has_positive_matcher(gate) { + return Err(format!("{context} must contain a positive matcher")); + } + validate_regex_patterns(&gate.regex, context, "regex")?; + validate_regex_patterns(&gate.line_regex, context, "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + if !gate_has_any_matcher(nested) { + return Err(format!("{context} contains an empty not gate")); + } + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_not_gate( + gate: &ManifestGate, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, "not gate", complexity)?; + if !gate_has_any_matcher(gate) { + return Err("not gate must contain a matcher".to_string()); + } + validate_regex_patterns(&gate.regex, "not gate", "regex")?; + validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "not all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "not any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_matcher_limits( + gate: &ManifestGate, + context: &str, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len(); + if matcher_count > MAX_MATCHERS_PER_GATE { + return Err(format!( + "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}" + )); + } + complexity.total_matchers += matcher_count; + if complexity.total_matchers > MAX_TOTAL_MATCHERS { + return Err(format!("manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}")); + } + for value in gate.contains.iter().chain(gate.regex.iter()).chain(gate.line_regex.iter()) { + if value.chars().count() > MAX_MATCHER_CHARS { + return Err(format!("{context} matcher exceeds max length {MAX_MATCHER_CHARS}")); + } + } + Ok(()) +} + +fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> { + for pattern in patterns { + Regex::new(pattern).map_err(|err| { + format!("{context} contains invalid {field} pattern {pattern:?}: {err}") + })?; + } + Ok(()) +} + +fn gate_has_positive_matcher(gate: &ManifestGate) -> bool { + !gate.contains.is_empty() + || !gate.regex.is_empty() + || !gate.line_regex.is_empty() + || !gate.all.is_empty() + || !gate.any.is_empty() +} + +fn gate_has_any_matcher(gate: &ManifestGate) -> bool { + gate_has_positive_matcher(gate) || !gate.not_gate.is_empty() +} + +fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { + ManifestGate { + all: rule.all.clone(), + any: rule.any.clone(), + not_gate: rule.not_gate.clone(), + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + } +} + +fn compile_manifest(manifest: &AgentManifest) -> Result, String> { + manifest + .rules + .iter() + .map(|rule| { + compile_gate(&manifest_gate_from_rule(rule)) + .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id)) + }) + .collect() +} + +fn validate_region_name(spec: &str) -> Result<(), String> { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" + | "after_last_prompt_marker" + | "before_current_prompt_marker" + | "whole_recent_without_current_prompt_marker" + | "current_prompt_block_marker" + | "after_current_prompt_block_marker" + | "prompt_box_body" + | "above_prompt_box" + | "last_non_empty_above_prompt_box" + | "after_last_horizontal_rule" + | "osc_title" + | "osc_progress" => Ok(()), + _ if region_count(trimmed, "bottom_lines").is_some() + || region_count(trimmed, "bottom_non_empty_lines").is_some() + || top_region_count(trimmed).is_some() => + { + Ok(()) + } + _ => Err(trimmed.to_string()), + } +} + +fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str { + let trimmed = spec.trim(); + // OSC regions source from their dedicated fields, not the screen. + match trimmed { + "osc_title" => return input.osc_title, + "osc_progress" => return input.osc_progress, + _ => {} + } + let content = input.screen; + match trimmed { + "whole_recent" => content, + "after_last_prompt_marker" => after_last_prompt_marker(content), + "before_current_prompt_marker" => before_current_prompt_marker(content), + "whole_recent_without_current_prompt_marker" => { + whole_recent_without_current_prompt_marker(content) + } + "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""), + "after_current_prompt_block_marker" => { + after_current_prompt_block_marker(content).unwrap_or("") + } + "prompt_box_body" => prompt_box_body(content).unwrap_or(""), + "above_prompt_box" => above_prompt_box(content), + "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)), + "after_last_horizontal_rule" => after_last_horizontal_rule(content), + _ => { + if let Some(count) = region_count(trimmed, "bottom_lines") { + return bottom_lines(content, count); + } + if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") { + return bottom_non_empty_lines(content, count); + } + if let Some(count) = top_region_count(trimmed) { + return top_non_empty_lines(content, count); + } + "" + } + } +} + +fn region_count(spec: &str, name: &str) -> Option { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|count| count.parse::().ok()) +} + +const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize; + +fn top_region_count(spec: &str) -> Option { + let count = spec.strip_prefix("top_non_empty_lines")?.strip_prefix('(')?.strip_suffix(')')?; + if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + count.parse::().ok().filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT) +} + +fn bottom_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(count); + slice_from_line_index(content, &lines, start) +} + +fn bottom_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(start_index) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + slice_from_line_index(content, &lines, start_index) +} + +fn top_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(end_index) = lines + .iter() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + let byte_offset = line_start_offset(content, &lines, end_index + 1); + &content[..byte_offset] +} + +fn after_last_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else { + return content; + }; + slice_from_line_index(content, &lines, index + 1) +} + +fn before_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = current_codex_prompt_index(&lines) else { + return content; + }; + let byte_offset = lines[..index].iter().map(|line| line.len() + 1).sum::(); + &content[..byte_offset.min(content.len())] +} + +fn whole_recent_without_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + if current_codex_prompt_index(&lines).is_some() { "" } else { content } +} + +fn current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + lines[..prompt_index].iter().rev().find(|line| codex_block_marker_line(line)).copied() +} + +fn after_current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + let block_index = + lines[..prompt_index].iter().rposition(|line| codex_block_marker_line(line))?; + Some(slice_from_line_index(content, &lines, block_index)) +} + +fn current_codex_prompt_index(lines: &[&str]) -> Option { + let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; + if lines[prompt_index + 1..].iter().any(|line| codex_block_marker_line(line)) { + return None; + } + Some(prompt_index) +} + +fn codex_prompt_line(line: &str) -> bool { + line == "›" || line.starts_with("› ") +} + +fn codex_block_marker_line(line: &str) -> bool { + line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') +} + +fn prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start.min(content.len())..end.min(content.len())]) +} + +fn above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(top) = prompt_box_top_border_index(&lines) else { + return content; + }; + let end = line_start_offset(content, &lines, top); + &content[..end.min(content.len())] +} + +fn after_last_horizontal_rule(content: &str) -> &str { + let mut last_rule_end = 0usize; + let mut offset = 0usize; + for line in content.lines() { + let next_offset = offset + line.len() + 1; + if is_horizontal_rule(line) { + last_rule_end = next_offset.min(content.len()); + } + offset = next_offset; + } + &content[last_rule_end..] +} + +fn last_non_empty_line(content: &str) -> &str { + content.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("") +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + let rule_bytes = + trimmed.char_indices().nth(rule_chars).map(|(index, _)| index).unwrap_or(trimmed.len()); + let suffix = trimmed[rule_bytes..].trim_start(); + suffix.is_empty() || rule_chars >= 3 +} + +fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str { + let byte_offset = line_start_offset(content, lines, index); + &content[byte_offset.min(content.len())..] +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::() + .min(content.len()) +} + +fn compile_gate(gate: &ManifestGate) -> Result { + Ok(CompiledGate { + all: gate.all.iter().map(compile_gate).collect::>()?, + any: gate.any.iter().map(compile_gate).collect::>()?, + not_gate: gate.not_gate.iter().map(compile_gate).collect::>()?, + contains: gate.contains.iter().map(|needle| needle.to_lowercase()).collect(), + regex: gate + .regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + line_regex: gate + .line_regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(screen: &str) -> DetectionInput<'_> { + DetectionInput { screen, osc_title: "", osc_progress: "" } + } + + #[test] + fn screen_detect_bundled_manifests_all_parse_and_identify() { + let set = ManifestSet::bundled(); + let ids: Vec<&str> = set.manifests().map(CompiledManifest::id).collect(); + assert_eq!(ids.len(), 21, "all vendored manifests load: {ids:?}"); + for expected in [ + "amp", "agy", "claude", "cline", "codex", "cursor", "devin", "droid", "gemini", "grok", + "hermes", "kilo", "kimi", "kiro", "maki", "muse", "opencode", "pi", "qodercli", "qwen", + "copilot", + ] { + assert_eq!( + set.identify(expected).map(CompiledManifest::id), + Some(expected), + "{expected} identifies itself" + ); + } + } + + #[test] + fn screen_detect_identify_normalizes_paths_aliases_and_case() { + let set = ManifestSet::bundled(); + for (name, id) in [ + ("/opt/homebrew/bin/codex", "codex"), + ("claude-code", "claude"), + ("CLAUDE", "claude"), + ("cursor-agent", "cursor"), + ("opencode.exe", "opencode"), + ("github-copilot", "copilot"), + (r"C:\Users\dev\kiro-cli.exe", "kiro"), + ] { + assert_eq!(set.identify(name).map(CompiledManifest::id), Some(id), "{name}"); + } + for shell in ["bash", "zsh", "vim", "node", "muse-helper", "codex-helper", ""] { + assert!(set.identify(shell).is_none(), "{shell} is not an agent"); + } + } + + #[test] + fn screen_detect_rule_priority_and_gates_pick_the_strongest_match() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "low" +state = "idle" +priority = 1 +contains = ["match"] + +[[rules]] +id = "high" +state = "working" +priority = 10 +contains = ["match"] +all = [{ any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] }] +not = [{ contains = ["suppressed"] }] + +[[rules]] +id = "line" +state = "blocked" +priority = 5 +line_regex = ["^prompt: .*\\?$"] +"#, + ) + .unwrap(); + + let matched = manifest.detect(input("a match that won")); + assert_eq!(matched.state, ScreenState::Working); + assert_eq!(matched.matched_rule.as_deref(), Some("high")); + + // The not gate suppresses the strong rule; the weak one remains. + let suppressed = manifest.detect(input("a match that won but suppressed")); + assert_eq!(suppressed.state, ScreenState::Idle); + assert_eq!(suppressed.matched_rule.as_deref(), Some("low")); + + // line_regex must match one whole line, not the flattened text. + let lined = manifest.detect(input("noise\nprompt: continue?\ntail")); + assert_eq!(lined.state, ScreenState::Blocked); + let unlined = manifest.detect(input("prompt: continue? trailing")); + assert_eq!(unlined.state, ScreenState::Idle); + assert_eq!(unlined.matched_rule, None, "known-agent idle fallback"); + } + + #[test] + fn screen_detect_regions_scope_matching_to_screen_slices() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "tail" +state = "working" +priority = 10 +region = "bottom_non_empty_lines(2)" +contains = ["spinner"] + +[[rules]] +id = "title" +state = "blocked" +priority = 20 +region = "osc_title" +contains = ["action required"] +"#, + ) + .unwrap(); + + let tail = manifest.detect(input("spinner far above\nline\nlast\nend")); + assert_eq!(tail.state, ScreenState::Idle, "match above the bottom slice is out of scope"); + let hit = manifest.detect(input("above\nline\nspinner here\nend")); + assert_eq!(hit.state, ScreenState::Working); + + let titled = manifest.detect(DetectionInput { + screen: "plain", + osc_title: "⚠ Action Required", + osc_progress: "", + }); + assert_eq!(titled.state, ScreenState::Blocked); + } + + #[test] + fn screen_detect_codex_manifest_classifies_live_screens() { + let set = ManifestSet::bundled(); + let codex = set.identify("codex").unwrap(); + + let working = codex.detect(input("context\n\n• Working (esc to interrupt)\n› \n")); + assert_eq!(working.state, ScreenState::Working); + + let blocked = codex.detect(input("$ rm -rf build\nAllow command?\n")); + assert_eq!(blocked.state, ScreenState::Blocked); + + let idle = codex.detect(input("ordinary prompt text")); + assert_eq!(idle.state, ScreenState::Idle); + assert!(idle.matched_rule.is_none()); + + let viewer = codex.detect(input( + "› old prompt\ntranscript\n↑/↓ to scroll pgup/pgdn to page\nhome/end to jump q to quit esc to edit prev\n", + )); + assert!(viewer.skip_state_update, "transcript viewer keeps the prior state"); + } + + #[test] + fn screen_detect_manifest_validation_rejects_malformed_sources() { + for (source, why) in [ + ("id = \"x\"\n", "no rules"), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nregion = \"nope\"\ncontains = [\"a\"]\n", + "unknown region", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"working\"\nskip_state_update = true\ncontains = [\"a\"]\n", + "skip_state_update requires unknown state", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nregex = [\"(\"]\n", + "invalid regex", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nsurprise = true\ncontains = [\"a\"]\n", + "unknown field", + ), + ( + "id = \"x\"\nmin_engine_version = 99\n[[rules]]\nid = \"r\"\nstate = \"idle\"\ncontains = [\"a\"]\n", + "future engine version", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nnot = [{ contains = [\"a\"] }]\n", + "not-only rule has no positive matcher", + ), + ] { + assert!(compile_manifest_source(source).is_err(), "{why}"); + } + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs b/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs new file mode 100644 index 000000000000..b5b4e8242477 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs @@ -0,0 +1,391 @@ +//! Screen-derived agent lifecycle detection. +//! +//! Detection semantics derived from herdr (https://github.com/herdrdev/herdr), +//! Apache-2.0, commit 7b675f42af35, modified by manaflow. +//! +//! The daemon watches every PTY's output stream; when a terminal goes +//! quiet (debounced), the foreground process name selects a vendored +//! herdr manifest and the terminal tail is evaluated against it. State +//! TRANSITIONS (never per-scan states) append `agent.*` journal events +//! with `native_event = "ScreenDetect"`, so the journal-derived roster +//! covers agents that expose no hooks (codex today). The engine port +//! lives in [`manifest`]; this module owns the pure edge-trigger +//! bookkeeping and the daemon-side scanner loop. + +pub(crate) mod manifest; +pub(crate) mod scanner; + +use std::collections::HashMap; +use std::time::Instant; + +use crate::AgentState; +use manifest::{Detection, ScreenState}; + +/// Output must be quiet this long before the screen is evaluated, so +/// mid-redraw frames are rarely matched. +pub(crate) const QUIESCENCE_DEBOUNCE_MS: u64 = 300; + +/// A screen that never goes quiet (agent spinners animate every ~100ms, +/// so a working codex never quiesces) is still evaluated at this pace. +/// Without it, quiescence gating starves detection during the exact +/// phase it exists to report. +pub(crate) const MAX_EVAL_INTERVAL_MS: u64 = 1_000; + +/// The `native_event` value screen-detection journal events carry. +pub(crate) const SCREEN_DETECT_NATIVE_EVENT: &str = "ScreenDetect"; + +/// One state transition the scanner must journal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScreenDetectEmission { + pub(crate) terminal_id: String, + /// Manifest id of the detected agent (`codex`, `claude`, ...). + pub(crate) agent: String, + pub(crate) state: AgentState, +} + +#[derive(Debug, Default)] +struct TrackedTerminal { + /// Last observed output-stream revision. + revision: u64, + /// When that revision was first observed (debounce anchor). + quiet_since: Option, + /// Revision already evaluated; skip re-evaluating identical screens. + evaluated_revision: Option, + /// When the screen was last evaluated (the max-interval pacer anchor). + last_evaluated_at: Option, + /// Agent the foreground process matched on the previous scan; identity + /// edges trigger immediate evaluation, before any quiescence. + foreground_agent: Option, + /// Last (agent, state) journaled; emissions are edges over this. + emitted: Option<(String, AgentState)>, +} + +/// Pure edge-trigger state for the scanner. All timing is passed in, so +/// tests drive it deterministically. +#[derive(Debug, Default)] +pub(crate) struct ScreenDetectTracker { + terminals: HashMap, +} + +impl ScreenDetectTracker { + /// Record the terminal's current output revision. Returns `true` when + /// the screen changed since the last evaluation and either output has + /// been quiet for the debounce window or the max-interval pacer is due + /// (a never-quiet spinner screen still evaluates at 1Hz; quiescence + /// alone starves detection during the exact phase it must report). A + /// `true` return arms the pacer: the caller always evaluates then. + pub(crate) fn observe_revision( + &mut self, + terminal_id: &str, + revision: u64, + now: Instant, + ) -> bool { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + if entry.quiet_since.is_none() || entry.revision != revision { + entry.revision = revision; + entry.quiet_since = Some(now); + } + if entry.evaluated_revision == Some(entry.revision) { + return false; + } + let quiet_since = entry.quiet_since.expect("anchored above"); + let quiesced = now.duration_since(quiet_since).as_millis() as u64 >= QUIESCENCE_DEBOUNCE_MS; + let overdue = entry.last_evaluated_at.is_none_or(|evaluated_at| { + now.duration_since(evaluated_at).as_millis() as u64 >= MAX_EVAL_INTERVAL_MS + }); + if quiesced || overdue { + entry.last_evaluated_at = Some(now); + return true; + } + false + } + + /// True when this terminal previously journaled a screen-derived state + /// that has not been closed out by an exit emission. + pub(crate) fn has_live_emission(&self, terminal_id: &str) -> bool { + self.terminals.get(terminal_id).is_some_and(|entry| entry.emitted.is_some()) + } + + /// Record which agent the foreground process currently matches. Returns + /// `true` on an identity edge (spawn, swap, or exit), which evaluates + /// the screen immediately: presence comes from the process, so the row + /// appears the moment `codex` starts, not after its first quiet screen. + pub(crate) fn note_foreground_agent(&mut self, terminal_id: &str, agent: Option<&str>) -> bool { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + if entry.foreground_agent.as_deref() == agent { + return false; + } + entry.foreground_agent = agent.map(str::to_string); + true + } + + /// Fold one evaluated detection. `None` detection means the foreground + /// process is not a supported agent (or is gone): a live screen-derived + /// entry is closed with a session-ended-equivalent `Done` emission. + pub(crate) fn record_detection( + &mut self, + terminal_id: &str, + detection: Option<(&str, Detection)>, + ) -> Option { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + entry.evaluated_revision = Some(entry.revision); + let Some((agent, detection)) = detection else { + let (agent, _) = entry.emitted.take()?; + return Some(ScreenDetectEmission { + terminal_id: terminal_id.to_string(), + agent, + state: AgentState::Done, + }); + }; + let asserted = if detection.skip_state_update { + // Agent-owned viewer (transcript scroll etc.): keep prior state. + None + } else { + match detection.state { + ScreenState::Working => Some(AgentState::Working), + ScreenState::Blocked => Some(AgentState::Blocked), + ScreenState::Idle => Some(AgentState::Idle), + // A matched unknown-state rule asserts nothing. + ScreenState::Unknown => None, + } + }; + let state = match (asserted, &entry.emitted) { + (Some(state), _) => state, + // The screen asserts nothing but the process IS the agent: + // presence must not wait for a stable screen, so the first + // emission for a terminal is idle until a later scan refines. + (None, None) => AgentState::Idle, + // A live emission keeps its prior state through viewer screens. + (None, Some(_)) => return None, + }; + let next = (agent.to_string(), state); + if entry.emitted.as_ref() == Some(&next) { + return None; + } + entry.emitted = Some(next); + Some(ScreenDetectEmission { + terminal_id: terminal_id.to_string(), + agent: agent.to_string(), + state, + }) + } + + /// Drop terminals that left the session. Closed terminals are retired + /// from the roster by the terminal lifecycle, not by an exit emission. + pub(crate) fn retain_terminals(&mut self, live: impl Fn(&str) -> bool) { + self.terminals.retain(|terminal_id, _| live(terminal_id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{HashSet, hash_map::DefaultHasher}; + use std::hash::BuildHasher; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[derive(Clone)] + struct CountingBuildHasher { + builds: Arc, + } + + impl BuildHasher for CountingBuildHasher { + type Hasher = DefaultHasher; + + fn build_hasher(&self) -> Self::Hasher { + self.builds.fetch_add(1, Ordering::Relaxed); + DefaultHasher::new() + } + } + + fn detection(state: ScreenState) -> Detection { + Detection { state, skip_state_update: false, matched_rule: Some("rule".into()) } + } + + #[test] + fn screen_detect_tracker_debounces_quiescence_per_revision() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + // A never-evaluated terminal evaluates on first sight. + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + // An unchanged screen never re-evaluates. + assert!(!tracker.observe_revision("term_a", 1, at(400))); + + // New output re-arms the debounce; inside the window with a recent + // evaluation nothing fires. + assert!(!tracker.observe_revision("term_a", 2, at(500))); + assert!(!tracker.observe_revision("term_a", 2, at(700))); + // Quiet long enough: evaluate exactly once per revision. + assert!(tracker.observe_revision("term_a", 2, at(900))); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + assert!(!tracker.observe_revision("term_a", 2, at(1_100))); + } + + #[test] + fn screen_detect_tracker_paces_evaluation_of_never_quiet_spinner_screens() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + + // A working spinner redraws every ~100ms, so the screen never goes + // quiet for the debounce window. The pacer still evaluates at 1Hz; + // without it a working codex would stay idle forever. + let mut evaluations = 0; + for tick in 1..=25u64 { + if tracker.observe_revision("term_a", 1 + tick, at(tick * 100)) { + evaluations += 1; + tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + } + } + assert_eq!(evaluations, 2, "1Hz pacer under 2.5s of continuous output"); + assert!(tracker.has_live_emission("term_a")); + } + + #[test] + fn screen_detect_tracker_emits_only_state_edges() { + let mut tracker = ScreenDetectTracker::default(); + + let first = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert_eq!( + first, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Working, + }) + ); + // Same state again: no event (edge-triggered, never per-scan). + let repeat = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert_eq!(repeat, None); + // Transition to blocked emits. + let blocked = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))); + assert_eq!(blocked.map(|emission| emission.state), Some(AgentState::Blocked)); + } + + #[test] + fn screen_detect_tracker_closes_departed_agents_with_done() { + let mut tracker = ScreenDetectTracker::default(); + assert!( + tracker.record_detection("term_a", None).is_none(), + "a terminal that never emitted stays silent" + ); + + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert!(tracker.has_live_emission("term_a")); + let done = tracker.record_detection("term_a", None); + assert_eq!( + done, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Done, + }) + ); + assert!(!tracker.has_live_emission("term_a")); + assert_eq!(tracker.record_detection("term_a", None), None, "done is an edge too"); + } + + #[test] + fn screen_detect_tracker_keeps_prior_state_for_viewers_and_unknowns() { + let mut tracker = ScreenDetectTracker::default(); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + + let viewer = Detection { + state: ScreenState::Unknown, + skip_state_update: true, + matched_rule: Some("transcript_viewer".into()), + }; + assert_eq!(tracker.record_detection("term_a", Some(("codex", viewer))), None); + + let unknown = detection(ScreenState::Unknown); + assert_eq!(tracker.record_detection("term_a", Some(("codex", unknown))), None); + assert!(tracker.has_live_emission("term_a"), "working emission still owns the terminal"); + } + + #[test] + fn screen_detect_tracker_flags_identity_edges_for_immediate_evaluation() { + let mut tracker = ScreenDetectTracker::default(); + // Shell pane: no agent means no edge, first scan included. + assert!(!tracker.note_foreground_agent("term_a", None)); + assert!(!tracker.note_foreground_agent("term_a", None)); + // codex launches: edge fires once, then the identity is steady. + assert!(tracker.note_foreground_agent("term_a", Some("codex"))); + assert!(!tracker.note_foreground_agent("term_a", Some("codex"))); + // Swapping agents in place is an edge, and so is exiting. + assert!(tracker.note_foreground_agent("term_a", Some("claude"))); + assert!(tracker.note_foreground_agent("term_a", None)); + } + + #[test] + fn screen_detect_tracker_emits_idle_presence_when_the_first_screen_asserts_nothing() { + let mut tracker = ScreenDetectTracker::default(); + // First evaluation right after spawn hits a viewer/unknown screen: + // presence must not wait for a stable screen. + let viewer = Detection { + state: ScreenState::Unknown, + skip_state_update: true, + matched_rule: Some("transcript_viewer".into()), + }; + let presence = tracker.record_detection("term_a", Some(("codex", viewer))); + assert_eq!( + presence, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Idle, + }) + ); + + let unknown = detection(ScreenState::Unknown); + assert_eq!( + tracker + .record_detection("term_b", Some(("codex", unknown))) + .map(|emission| emission.state), + Some(AgentState::Idle) + ); + } + + #[test] + fn screen_detect_tracker_prunes_closed_terminals_with_one_lookup_each() { + const LIVE_COUNT: usize = 1_024; + const CLOSED_COUNT: usize = 1_024; + + let mut tracker = ScreenDetectTracker::default(); + let live_ids: Vec = (0..LIVE_COUNT).map(|index| format!("live-{index}")).collect(); + let closed_ids: Vec = + (0..CLOSED_COUNT).map(|index| format!("closed-{index}")).collect(); + for terminal_id in live_ids.iter().chain(&closed_ids) { + tracker.record_detection(terminal_id, Some(("codex", detection(ScreenState::Working)))); + } + + let builds = Arc::new(AtomicUsize::new(0)); + let mut live = HashSet::with_capacity_and_hasher( + LIVE_COUNT, + CountingBuildHasher { builds: builds.clone() }, + ); + live.extend(live_ids.iter().map(String::as_str)); + builds.store(0, Ordering::Relaxed); + + tracker.retain_terminals(|terminal_id| live.contains(terminal_id)); + + assert_eq!( + builds.load(Ordering::Relaxed), + LIVE_COUNT + CLOSED_COUNT, + "retention must make one indexed membership lookup per tracked terminal", + ); + assert!(live_ids.iter().all(|terminal_id| tracker.has_live_emission(terminal_id))); + assert!(closed_ids.iter().all(|terminal_id| !tracker.has_live_emission(terminal_id))); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/screen_detect/scanner.rs b/cmux-tui/crates/cmux-tui-core/src/screen_detect/scanner.rs new file mode 100644 index 000000000000..aed179813dba --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/screen_detect/scanner.rs @@ -0,0 +1,122 @@ +//! Daemon-side screen-detection scanner. +//! +//! Detection semantics derived from herdr (https://github.com/herdrdev/herdr), +//! Apache-2.0, commit 7b675f42af35, modified by manaflow. +//! +//! One session-owned thread samples every live PTY's coalesced output +//! revision on a fixed cadence. A terminal that stays on one revision for +//! the debounce window is quiesced: its foreground process name picks a +//! manifest and the viewport tail plus OSC title are evaluated. The +//! tracker turns per-scan states into edge-triggered journal emissions. + +use std::collections::HashSet; +use std::sync::{Arc, Weak}; +use std::time::{Duration, Instant}; + +use super::ScreenDetectTracker; +use super::manifest::{DetectionInput, ManifestSet}; +use crate::mux::Mux; +use crate::surface::Surface; + +/// Sampling cadence: per terminal per tick, one atomic revision read plus +/// one foreground process-group lookup (two proc syscalls). Viewport text +/// is only extracted on quiescence or an identity edge. The cadence also +/// bounds spawn-detection latency: a launched agent appears within one +/// tick plus the journal fold. +const SCAN_INTERVAL: Duration = Duration::from_millis(100); + +/// Resolves a surface to its foreground process name; injectable so tests +/// drive detection without spawning agent-named processes. +pub(crate) type ProcessNameResolver = dyn Fn(&Surface) -> Option + Send + Sync; + +pub(crate) fn start(mux: &Arc) { + let weak = Arc::downgrade(mux); + let spawned = std::thread::Builder::new() + .name("mux-screen-detect".into()) + .spawn(move || run_scanner(weak)); + if let Err(error) = spawned { + eprintln!("cmux-tui: screen-detection scanner did not start: {error}"); + } +} + +fn run_scanner(weak: Weak) { + let mut tracker = ScreenDetectTracker::default(); + let manifests = ManifestSet::bundled(); + loop { + { + let Some(mux) = weak.upgrade() else { return }; + if mux.daemon_shutdown_requested() { + return; + } + scan(&mux, &mut tracker, manifests, Instant::now(), &foreground_process_name); + } + // Fixed sampling cadence (the debounce clock), not a synchronization + // substitute: nothing waits on this thread. + std::thread::sleep(SCAN_INTERVAL); + } +} + +/// Production resolver: the foreground process-group leader's executable +/// name for the terminal's PTY child. An exited terminal resolves to none. +fn foreground_process_name(surface: &Surface) -> Option { + if surface.terminal_exit().is_some() { + return None; + } + crate::platform::foreground_process_name(surface.process_id()?) +} + +/// One scan pass over the live terminal catalog. Pure over its inputs +/// besides journal emission, so tests call it directly with a stub +/// resolver and a controlled clock. +pub(crate) fn scan( + mux: &Mux, + tracker: &mut ScreenDetectTracker, + manifests: &ManifestSet, + now: Instant, + resolver: &ProcessNameResolver, +) { + let terminals = mux.screen_detect_terminals(); + // Build an index once. The tracker can contain thousands of retired + // terminals, so scanning the live list for every tracked entry creates + // an avoidable O(tracked × live) pass on every tick. + let live_ids: HashSet<&str> = terminals.iter().map(|(id, _)| id.as_str()).collect(); + tracker.retain_terminals(|terminal_id| live_ids.contains(terminal_id)); + for (terminal_id, surface) in terminals { + let Ok(revision) = surface.terminal_stream_revision() else { continue }; + let terminal_id = terminal_id.as_str(); + let quiesced = tracker.observe_revision(terminal_id, revision, now); + // Identity is resolved every tick: presence comes from the + // foreground process, so a freshly launched agent is detected on + // the next scan, never gated behind output quiescence. + let manifest = resolver(&surface).and_then(|name| manifests.identify(&name)); + let identity_edge = + tracker.note_foreground_agent(terminal_id, manifest.map(|manifest| manifest.id())); + let emission = match manifest { + None => { + // Not an agent (or the agent exited). Closes a live + // screen-derived entry; a terminal that never emitted + // stays silent. + tracker.record_detection(terminal_id, None) + } + Some(manifest) if quiesced || identity_edge => { + let Ok(Ok(screen)) = surface.try_with_terminal(|terminal| terminal.viewport_text()) + else { + continue; + }; + let title = surface.title(); + let detection = manifest.detect(DetectionInput { + screen: &screen, + osc_title: &title, + // OSC 9;4 progress is not captured server-side yet; + // progress-region rules simply never match. + osc_progress: "", + }); + tracker.record_detection(terminal_id, Some((manifest.id(), detection))) + } + Some(_) => None, + }; + if let Some(emission) = emission { + mux.append_screen_detect_event(&emission); + } + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index d19682b9f996..a48f2427dde5 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -10323,6 +10323,7 @@ fn agent_json(record: &AgentRecord) -> Value { "state": record.state.as_str(), "source": record.source.as_str(), "session": record.session, + "agent": record.agent, "updated_at_ms": record.updated_at_ms, }) } diff --git a/cmux-tui/vendor/herdr-manifests/LICENSE b/cmux-tui/vendor/herdr-manifests/LICENSE new file mode 100644 index 000000000000..261eeb9e9f8b --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cmux-tui/vendor/herdr-manifests/README.md b/cmux-tui/vendor/herdr-manifests/README.md new file mode 100644 index 000000000000..0cda0e13c5f3 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/README.md @@ -0,0 +1,6 @@ +# herdr agent-detection manifests + +Vendored from https://github.com/herdrdev/herdr (Apache-2.0, see LICENSE) +at commit 7b675f42af35508eab66ac42fe1598628597a893, path src/detect/manifests/. Modified by manaflow only as +noted per file. Do not fetch herdr's manifest update endpoint; refresh by +re-vendoring and bumping this pin. diff --git a/cmux-tui/vendor/herdr-manifests/amp.toml b/cmux-tui/vendor/herdr-manifests/amp.toml new file mode 100644 index 000000000000..1fc5dfa6bbab --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/amp.toml @@ -0,0 +1,65 @@ +id = "amp" +version = "2026.07.09.1" +min_engine_version = 2 +updated_at = "2026-07-09T00:00:00Z" +aliases = ["amp-local"] + +[[rules]] +id = "osc_title_plugin_confirmation_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Plugin confirmation needed"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^[\x{2800}-\x{28FF}] '] + +[[rules]] +id = "approval_footer" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval"] }, + { contains = ["invoke tool"] }, + { contains = ["run this command?"] }, + { contains = ["allow editing file:"] }, + { contains = ["allow creating file:"] }, + { contains = ["confirm tool call"] }, + { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] }, +] + +[[rules]] +id = "status_footer_working" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)^\s*╰\s+\S+\s+(thinking|streaming|running tools|waiting)\s+─'] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 50 +region = "osc_title" +visible_idle = true +contains = [" - amp - "] +not = [ + { regex = ['^[\x{2800}-\x{28FF}] '] }, + { contains = ["Plugin confirmation needed"] }, +] diff --git a/cmux-tui/vendor/herdr-manifests/antigravity.toml b/cmux-tui/vendor/herdr-manifests/antigravity.toml new file mode 100644 index 000000000000..8b9bcfcf4aa5 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/antigravity.toml @@ -0,0 +1,33 @@ +id = "agy" +version = "2026.06.24.1" +min_engine_version = 1 +updated_at = "2026-06-24T00:00:00Z" +aliases = ["antigravity", "antigravity-cli"] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["requesting permission for:"] +any = [ + { contains = ["do you want to proceed?"] }, + { contains = ["tab amend", "edit command"] }, +] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] + +[[rules]] +id = "background_tasks_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] diff --git a/cmux-tui/vendor/herdr-manifests/claude.toml b/cmux-tui/vendor/herdr-manifests/claude.toml new file mode 100644 index 000000000000..696a19789084 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/claude.toml @@ -0,0 +1,207 @@ +id = "claude" +version = "2026.08.21.1" +min_engine_version = 2 +updated_at = "2026-08-21T00:00:00Z" +aliases = ["claude-code"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner. +regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] '] + +[[rules]] +id = "live_turn_working" +state = "working" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_working = true +any = [ + { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] }, + { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] }, +] + +[[rules]] +id = "background_shell_working" +state = "working" +priority = 965 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['^\s*[⏸⏵].*·\s+[1-9]\d*\s+shells?\s+(?:·|$)'] + +[[rules]] +id = "background_agents_working" +state = "working" +priority = 965 +region = "last_non_empty_above_prompt_box" +visible_working = true +line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+Waiting for [1-9]\d* background agents? to finish\s*$'] + +[[rules]] +id = "background_mcp_task_working" +state = "working" +priority = 965 +region = "bottom_non_empty_lines(12)" +visible_working = true +# Claude renders activity summaries at column zero; wrapped continuations are indented. +# Keeping that shape prevents user prompt text from impersonating this signal. +regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$'] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["esc to cancel"] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] + +[[rules]] +id = "btw_overlay_working" +state = "working" +priority = 975 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = [ + '^\s*/btw(?:\s|$)', + '(?i)esc to close\s*$', +] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "bottom_non_empty_lines(3)" +skip_state_update = true +contains = ["showing detailed transcript"] +any = [ + { contains = ["ctrl+o", "to toggle"] }, + { contains = ["ctrl+e", "show all"] }, + { contains = ["ctrl+e", "collapse"] }, + { contains = ["↑↓ scroll"] }, + { contains = ["? for shortcuts"] }, +] + +[[rules]] +id = "live_blocked_form" +state = "blocked" +priority = 980 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["esc to cancel"] +any = [ + { contains = ["enter to confirm"] }, + { contains = ["enter to select"], any = [ + { contains = ["tab/arrow keys to navigate"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["arrows to navigate"] }, + { contains = ["↑/↓ to navigate"] }, + { contains = ["↑↓ to navigate"] }, + ] }, +] + +[[rules]] +id = "dynamic_workflow_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["run a dynamic workflow?", "esc to cancel"] + +[[rules]] +id = "live_prompt_box" +state = "idle" +priority = 950 +region = "prompt_box_body" +visible_idle = true +line_regex = ['^\s*❯'] +not = [ + { contains = ["enter to select"] }, + { contains = ["esc to cancel"] }, + { contains = ["tab/arrow keys"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["↑/↓ to navigate"] }, +] + +[[rules]] +id = "model_picker_menu" +state = "unknown" +priority = 900 +region = "whole_recent" +skip_state_update = true +contains = ["select model", "enter to set as default", "esc to cancel"] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["enter to select"] }, +] + +[[rules]] +id = "bash_permission_prompt" +state = "blocked" +priority = 850 +region = "whole_recent" +visible_blocker = true +contains = ["do you want to proceed?"] +any = [ + { contains = ["bash command"] }, + { contains = ["bash("] }, + { contains = ["contains expansion"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] +all = [ + { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, +] + +[[rules]] +id = "generic_permission_prompt" +state = "blocked" +priority = 840 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["do you want to proceed?", "esc to cancel"] +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "legacy_no_prompt_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +any = [ + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, + { contains = ["do you want to proceed?", "esc to cancel"] }, + { contains = ["review your answers"] }, + { contains = ["skip interview and plan immediately"] }, +] +not = [ + { regex = ['(?m)^\s*❯\s*$'] }, +] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 250 +region = "osc_title" +visible_idle = true +regex = ['^\x{2733} '] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 250 +region = "osc_progress" +regex = ['^4;0'] diff --git a/cmux-tui/vendor/herdr-manifests/cline.toml b/cmux-tui/vendor/herdr-manifests/cline.toml new file mode 100644 index 000000000000..b30f4417ce6f --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/cline.toml @@ -0,0 +1,26 @@ +id = "cline" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "tool_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["let cline use this tool"] }, + { contains = ["[act mode]", "execute command?", "yes"] }, + { contains = ["[act mode]", "use this tool?", "yes"] }, + { contains = ["[plan mode]", "execute command?", "yes"] }, + { contains = ["[plan mode]", "use this tool?", "yes"] }, +] + +[[rules]] +id = "default_cline_working" +state = "working" +priority = -10 +region = "whole_recent" +visible_working = true +regex = ['(?s).+'] diff --git a/cmux-tui/vendor/herdr-manifests/codex.toml b/cmux-tui/vendor/herdr-manifests/codex.toml new file mode 100644 index 000000000000..9169e10848e0 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/codex.toml @@ -0,0 +1,89 @@ +id = "codex" +version = "2026.08.09.1" +min_engine_version = 3 +updated_at = "2026-08-09T00:00:00Z" + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "after_last_prompt_marker" +skip_state_update = true +contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"] +any = [ + { contains = ["esc to edit prev"] }, + { contains = ["esc/← to edit prev"] }, +] + +[[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, +] + +[[rules]] +id = "live_strong_blocker" +state = "blocked" +priority = 900 +region = "after_last_prompt_marker" +visible_blocker = true +any = [ + { contains = ["press enter to confirm or esc to cancel"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, +] + +[[rules]] +id = "weak_blocker" +state = "blocked" +priority = 600 +region = "whole_recent" +any = [ + { contains = ["[y/n]"] }, + { contains = ["yes (y)"] }, + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, +] + +[[rules]] +id = "screen_working_fallback" +state = "working" +priority = 500 +region = "bottom_non_empty_lines(3)" +visible_working = true +line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$'] +not = [{ contains = ["■ Conversation interrupted"] }] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, + { contains = ["Action Required"] }, +] diff --git a/cmux-tui/vendor/herdr-manifests/cursor.toml b/cmux-tui/vendor/herdr-manifests/cursor.toml new file mode 100644 index 000000000000..ee03e6db9d75 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/cursor.toml @@ -0,0 +1,57 @@ +id = "cursor" +version = "2026.08.03.1" +min_engine_version = 1 +updated_at = "2026-08-03T01:08:04Z" +aliases = ["cursor-agent"] + +[[rules]] +id = "write_file_approval" +state = "blocked" +priority = 320 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["write to this file?", "proceed (y)"] +any = [ + { contains = ["reject & propose changes"] }, + { contains = ["esc or n or p"] }, + { contains = ["add write("] }, +] + +[[rules]] +id = "approval_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] }, + { contains = ["(y) (enter)"] }, + { line_regex = ['(?i)^\s*allow .*\(y\)'] }, + { contains = ["keep (n)"] }, + { contains = ["skip (esc or n)"] }, + { line_regex = ['(?i)^\s*(?:→\s*)?run .*\(y\)'] }, +] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["ctrl+c to stop"] + +[[rules]] +id = "background_task_status_working" +state = "working" +priority = 95 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b'] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b'] diff --git a/cmux-tui/vendor/herdr-manifests/devin.toml b/cmux-tui/vendor/herdr-manifests/devin.toml new file mode 100644 index 000000000000..c9564f7cc134 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/devin.toml @@ -0,0 +1,86 @@ +id = "devin" +version = "2026.06.15.1" +min_engine_version = 1 +updated_at = "2026-06-15T00:00:00Z" +aliases = ["devin-cli", "devin cli"] + +[[rules]] +id = "workspace_trust_prompt" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", +] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["approve once", "select", "confirm", "esc cancel"] + +[[rules]] +id = "running_tools_footer" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["running tools", "esc to interrupt"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "guide_while_working" +state = "working" +priority = 190 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["guide devin while it works"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "tool_reading_timeout" +state = "working" +priority = 180 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["reading shell ", "timeout:"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "welcome_prompt_footer" +state = "idle" +priority = 120 +region = "bottom_non_empty_lines(8)" +visible_idle = true +contains = ["ask devin to build", "features, fix bugs", "your code"] +line_regex = ['^\s*❭ Ask Devin to build'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] + +[[rules]] +id = "live_prompt_footer" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_idle = true +contains = ["context:"] +line_regex = ['^\s*❭'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] diff --git a/cmux-tui/vendor/herdr-manifests/droid.toml b/cmux-tui/vendor/herdr-manifests/droid.toml new file mode 100644 index 000000000000..c41d71b43bfd --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/droid.toml @@ -0,0 +1,48 @@ +id = "droid" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "execute_selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["↑↓ to navigate"] }, + { contains = ["use ↑↓ to navigate"] }, +] +all = [ + { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] }, +] + +[[rules]] +id = "selection_menu_blocker" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["enter select", "esc cancel"] +any = [ + { contains = ["↑/↓ navigate"] }, + { contains = ["↑↓ navigate"] }, +] + +[[rules]] +id = "spinner_stop_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] +line_regex = ['^\s*[\u2800-\u28FF]'] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] diff --git a/cmux-tui/vendor/herdr-manifests/gemini.toml b/cmux-tui/vendor/herdr-manifests/gemini.toml new file mode 100644 index 000000000000..9d7a28e112d6 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/gemini.toml @@ -0,0 +1,25 @@ +id = "gemini" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "apply_or_allow_change" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["│ Apply this change"] }, + { contains = ["│ Allow execution"] }, + { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] }, + { line_regex = ['(?i)^\s*❯.*(yes|allow)'] }, +] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] diff --git a/cmux-tui/vendor/herdr-manifests/github-copilot.toml b/cmux-tui/vendor/herdr-manifests/github-copilot.toml new file mode 100644 index 000000000000..4233c563eb47 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/github-copilot.toml @@ -0,0 +1,37 @@ +id = "copilot" +version = "2026.07.07.1" +min_engine_version = 1 +updated_at = "2026-07-07T14:15:00Z" +aliases = ["github-copilot", "ghcs"] + +[[rules]] +id = "selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +all = [ + { any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + ] }, + { any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit"] }, + { contains = ["enter accept"] }, + ] }, +] + +[[rules]] +id = "working_cancel_hint" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc again to cancel"] }, + { contains = ["esc interrupt"] } +] diff --git a/cmux-tui/vendor/herdr-manifests/grok.toml b/cmux-tui/vendor/herdr-manifests/grok.toml new file mode 100644 index 000000000000..30b6e9c70438 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/grok.toml @@ -0,0 +1,164 @@ +id = "grok" +version = "2026.07.16.2" +min_engine_version = 3 +updated_at = "2026-07-16T00:00:00Z" +aliases = ["grok-build"] + +# Evidence: Grok Build 0.2.101 source and live pane reads. +# +# Grok emits OSC 0 titles by default. Idle is "grok" or +# " - grok". During a turn, the configured title gains a braille +# spinner and activity text. Permission prompts add "⚠ Action Required"; +# that prefix blinks while the terminal is unfocused, so visible blocker +# rules outrank the generic non-idle title rule. +# +# Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the +# payload after "9;": "4;1;-1" while busy and "4;0;0" when idle. +# +# Working turns render one live status line directly above the prompt box: +# "⠧ Waiting on subagent… 2.8s 13s ⇣29.7k [stop]" +# "⠴ Explore /tmp/… + 1 more… 5.6s 19s ⇣29.7k [stop]" +# with a braille spinner and a trailing [stop] chip, plus an Esc:cancel +# footer hint. Permission prompts and ask-user-question dialogs replace the +# spinner with "◆" and draw a "┃"-guttered option list: +# "┃ 2 (○) Yes, proceed" +# "┃ z (○) Type your answer here" +# with footer hints "1/3:select │ Ctrl+o:yolo │ Ctrl+c:cancel" (permission) +# or "Esc:unselect │ Tab:scrollback │ Shift+x:dismiss" (question dialog). +# Idle footers end with "Ctrl+.:shortcuts" and never contain "Esc:cancel". +# The startup splash draws its logo with braille characters, so working +# rules must anchor on the [stop] chip, not on a bare spinner glyph. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "option_dialog_blocked" +state = "blocked" +priority = 1200 +region = "whole_recent" +visible_blocker = true +line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s'] + +[[rules]] +id = "permission_hints_blocked" +state = "blocked" +priority = 1190 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"] + +[[rules]] +id = "question_dialog_hints_blocked" +state = "blocked" +priority = 1185 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["tab:scrollback", "shift+x:dismiss"] + +# Pre-0.2.x permission UI kept for older Grok Build releases. +[[rules]] +id = "permission_scope_selector" +state = "blocked" +priority = 1180 +region = "whole_recent" +visible_blocker = true +contains = ["yes, proceed", "no, reject"] +any = [ + { contains = ["use ← → to choose permission whitelist scope"] }, + { contains = ["←/→:scope"] }, +] + +# Grok clears its OSC busy signals while background work runs. The first +# non-empty row is pinned application chrome, where this animated chip shows +# the number of running background tasks and disappears when the count is zero. +[[rules]] +id = "background_work_chip_working" +state = "working" +priority = 1170 +region = "top_non_empty_lines(1)" +visible_working = true +line_regex = ['[⋅:⸬⁙.·]\s+[1-9][0-9]*\s+│'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 1150 +region = "osc_progress" +visible_working = true +regex = ['^4;1;-1$'] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 1100 +region = "osc_title" +visible_idle = true +regex = ['(?:^| - )grok$'] +not = [ + { regex = ['[\x{2800}-\x{28FF}]'] }, +] + +# After known idle titles are excluded, any other non-empty Grok title is +# active. OSC progress remains authoritative when a custom title omits the +# spinner but the terminal supports progress reporting. +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1000 +region = "osc_title" +visible_working = true +regex = ['\S'] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 950 +region = "osc_progress" +visible_idle = true +regex = ['^4;0;0$'] + +[[rules]] +id = "spinner_status_working" +state = "working" +priority = 200 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] + +[[rules]] +id = "esc_cancel_hints_working" +state = "working" +priority = 190 +region = "bottom_non_empty_lines(2)" +visible_working = true +contains = ["esc:cancel", "ctrl+.:shortcuts"] + +# Pre-0.2.x working chrome kept for older Grok Build releases. +[[rules]] +id = "waiting_tool_working" +state = "working" +priority = 120 +region = "whole_recent" +visible_working = true +any = [ + { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] }, + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+(Run|Read|Search|List)\b'] }, +] + +[[rules]] +id = "prompt_hints_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(2)" +visible_idle = true +contains = ["ctrl+.:shortcuts"] +not = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] diff --git a/cmux-tui/vendor/herdr-manifests/hermes.toml b/cmux-tui/vendor/herdr-manifests/hermes.toml new file mode 100644 index 000000000000..17542184971e --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/hermes.toml @@ -0,0 +1,102 @@ +id = "hermes" +version = "2026.07.24.1" +min_engine_version = 2 +updated_at = "2026-07-24T19:12:54Z" +aliases = ["hermes-agent"] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "dangerous_command_approval" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["dangerous"] }, + { contains = ["approval"] }, + { contains = ["allow once", "deny"] }, + { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, +] + +[[rules]] +id = "clarification_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["hermes needs your"] }, + { line_regex = ['^\s*ask\s+\S'] }, + { contains = ["type your answer"] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] }, +] + +[[rules]] +id = "credential_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["sudo password"] }, + { contains = ["skill setup"] }, + { contains = ["🔑", "for "] }, +] + +[[rules]] +id = "confirmation_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +all = [ + { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] }, + { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] }, +] + +[[rules]] +id = "interrupt_status_working" +state = "working" +priority = 950 +region = "bottom_non_empty_lines(5)" +visible_working = true +any = [ + { contains = ["msg=interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, +] + +[[rules]] +id = "classic_cancel_working" +state = "working" +priority = 500 +region = "bottom_non_empty_lines(5)" +visible_working = true +contains = ["ctrl+c cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)'] diff --git a/cmux-tui/vendor/herdr-manifests/kilo.toml b/cmux-tui/vendor/herdr-manifests/kilo.toml new file mode 100644 index 000000000000..4ef004e1de4a --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/kilo.toml @@ -0,0 +1,24 @@ +id = "kilo" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kilo-code", "kilo code", "herdr:kilo"] + +[[rules]] +id = "opencode_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "esc_interrupt_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc interrupt"] diff --git a/cmux-tui/vendor/herdr-manifests/kimi.toml b/cmux-tui/vendor/herdr-manifests/kimi.toml new file mode 100644 index 000000000000..b4d0100fbae7 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/kimi.toml @@ -0,0 +1,77 @@ +id = "kimi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kimi-code", "kimi code"] + +[[rules]] +id = "current_approval_panel" +state = "blocked" +priority = 400 +region = "whole_recent" +visible_blocker = true +contains = ["↵ confirm"] +any = [ + { contains = ["run this command?"] }, + { contains = ["write this file?"] }, + { contains = ["apply these edits?"] }, + { contains = ["stop this task?"] }, + { contains = ["ready to build with this plan?"] }, + { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] }, +] +all = [ + { contains = [" choose"] }, + { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] }, +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 390 +region = "whole_recent" +visible_blocker = true +contains = ["↑↓ select", "esc cancel"] +line_regex = ['^\s*question\s*$', '^\s*\? '] +any = [ + { contains = ["↵ choose"] }, + { contains = ["↵ toggle"] }, + { contains = ["↵ save"] }, +] + +[[rules]] +id = "legacy_approval_panel" +state = "blocked" +priority = 300 +region = "whole_recent" +contains = ["requesting approval", "reject"] +any = [ + { contains = ["approve once"] }, + { contains = ["approve for this session"] }, +] +all = [ + { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] }, +] + +[[rules]] +id = "background_agent_status_working" +state = "working" +priority = 120 +region = "bottom_non_empty_lines(3)" +visible_working = true +line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]'] + +[[rules]] +id = "moon_spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$'] + +[[rules]] +id = "braille_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )'] diff --git a/cmux-tui/vendor/herdr-manifests/kiro.toml b/cmux-tui/vendor/herdr-manifests/kiro.toml new file mode 100644 index 000000000000..59ac0a061b27 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/kiro.toml @@ -0,0 +1,63 @@ +id = "kiro" +version = "2026.08.01.1" +min_engine_version = 1 +updated_at = "2026-08-01T00:00:00Z" +aliases = ["kiro-cli"] + +[[rules]] +id = "tool_approval" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["requires approval"] +any = [ + { contains = ["yes, single permission"] }, + { contains = ["trust, always allow"] }, + { contains = ["no (tab to edit)"] }, + { contains = ["esc to close"] }, +] + +[[rules]] +id = "subagent_approval" +state = "blocked" +priority = 290 +region = "whole_recent" +visible_blocker = true +contains = ["pending from subagents"] +any = [ + { contains = ["tool approval"] }, + { contains = ["tool approvals"] }, +] +all = [ + { any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] }, +] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 200 +region = "bottom_non_empty_lines(5)" +visible_idle = true +contains = ["ask a question or describe a task", "/copy to clipboard"] +not = [ + { contains = ["kiro is working"] }, + { contains = ["esc to cancel"] }, +] + +[[rules]] +id = "kiro_working_marker" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["kiro is working"] + +[[rules]] +id = "tool_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] +line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}'] diff --git a/cmux-tui/vendor/herdr-manifests/maki.toml b/cmux-tui/vendor/herdr-manifests/maki.toml new file mode 100644 index 000000000000..5c58404a52a9 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/maki.toml @@ -0,0 +1,68 @@ +id = "maki" +version = "2026.07.09.2" +min_engine_version = 1 +updated_at = "2026-07-09T00:00:00Z" + +# Maki renders a persistent one-line status bar on the bottom row. It starts +# with the mode label "[BUILD]", "[PLAN]", or "[BASH]" when idle and gets a +# leading braille spinner cell while the agent is streaming. Permission +# requests and the plan-complete form replace the input box above the status +# bar. Maki does not set OSC title or OSC 9;4 progress. + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["permission required"] +any = [ + { contains = ["y allow", "n deny"] }, + { contains = ["confirm allow"] }, + { contains = ["confirm deny"] }, + { contains = ["enter deny", "esc cancel"] }, +] + +[[rules]] +id = "plan_complete_form" +state = "blocked" +priority = 970 +region = "whole_recent" +visible_blocker = true +contains = ["plan complete", "enter confirm"] +any = [ + { contains = ["space toggle parallel"] }, + { contains = ["edit plan"] }, +] + +[[rules]] +id = "status_bar_spinner_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(1)" +visible_working = true +line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} \[(BUILD|PLAN|BASH)\]'] + +[[rules]] +id = "status_bar_idle" +state = "idle" +priority = 850 +region = "bottom_non_empty_lines(1)" +visible_idle = true +line_regex = ['^ \[(BUILD|PLAN|BASH)\]'] + +# On narrow panes the right side of the status bar overwrites the mode label, +# so fall back to the prompt chevron above the input box border. The not-gates +# keep this from matching the streaming placeholder or a status bar that still +# shows the spinner. +[[rules]] +id = "prompt_box_idle" +state = "idle" +priority = 840 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^❯ '] +not = [ + { contains = ["queue another prompt"] }, + { line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} '] }, +] diff --git a/cmux-tui/vendor/herdr-manifests/muse.toml b/cmux-tui/vendor/herdr-manifests/muse.toml new file mode 100644 index 000000000000..50818422c3cb --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/muse.toml @@ -0,0 +1,113 @@ +id = "muse" +version = "2026.08.26.1" +min_engine_version = 2 +updated_at = "2026-08-26T00:00:00Z" +aliases = ["muse-code", "muse-cli"] + +# Evidence: live bottom-buffer captures from Muse Code 0.2.1 in Herdr, using a local +# deterministic Responses provider so each UI state could be held and inspected. +# +# Idle has a `⟩` prompt and `model · effort · cwd` footer. Active turns show +# `◆ Working (... · esc to interrupt)` or another activity label with the same interrupt hint. +# +# Structured questions show two co-occurring footer controls: +# Enter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt +# Multi-select uses `Enter to toggle` instead. The paired controls distinguish a real picker +# from ordinary transcript text that happens to mention one action. +# +# First launch in an untrusted directory shows `Do you trust this workspace?` together with +# `Trust and continue`. This is a real blocker. User-opened `/theme` and `/skills` menus are +# not blockers; their paired footer controls identify overlays whose prior state must be kept. +# +# Muse 0.2.1 command approval shows `Allow this stage once` together with +# `Always allow in this workspace`. Muse 0.1 used `Allow once` with +# `Allow for this session`. Network approval shows `Yes, proceed` together with +# `Yes, don't ask again this session`. Each approval rule requires a pair because Muse can +# emit any one of these phrases as ordinary assistant text after a completed turn. + +[[rules]] +id = "workspace_trust_blocked" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_blocker = true +contains = ["Do you trust this workspace?"] +any = [ + { contains = ["Trust and continue"] }, + { contains = ["Use Up/Down"] }, +] + +[[rules]] +id = "pick_request_blocked" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "menu_overlay" +state = "unknown" +priority = 940 +region = "bottom_non_empty_lines(8)" +skip_state_update = true +any = [ + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "working_esc_interrupt" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["esc to interrupt"] +not = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "blocked_approval" +state = "blocked" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Allow this stage once", "Always allow in this workspace"] }, + { contains = ["Allow once", "Allow for this session"] }, + { contains = ["Yes, proceed", "Yes, don't ask again this session"] }, +] + +[[rules]] +id = "idle_prompt" +state = "idle" +priority = 700 +region = "bottom_non_empty_lines(5)" +visible_idle = true +any = [ + { line_regex = ['^\s*⟩\s*$'] }, + { line_regex = ['^\s*⟩\s+\S'] }, +] +not = [ + { contains = ["esc to interrupt"] }, + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "idle_status_fallback" +state = "idle" +priority = 500 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^\s*\S+ · (none|minimal|low|medium|high|xhigh|ultra) · '] +not = [{ contains = ["esc to interrupt"] }] diff --git a/cmux-tui/vendor/herdr-manifests/opencode.toml b/cmux-tui/vendor/herdr-manifests/opencode.toml new file mode 100644 index 000000000000..5245238371da --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/opencode.toml @@ -0,0 +1,37 @@ +id = "opencode" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["open-code", "herdr:opencode"] + +[[rules]] +id = "permission_required" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, +] + +[[rules]] +id = "progress_bar_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +regex = ['(■|⬝){4,}'] diff --git a/cmux-tui/vendor/herdr-manifests/pi.toml b/cmux-tui/vendor/herdr-manifests/pi.toml new file mode 100644 index 000000000000..a58d30e90326 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/pi.toml @@ -0,0 +1,13 @@ +id = "pi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["herdr:pi"] + +[[rules]] +id = "working_literal" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["Working..."] diff --git a/cmux-tui/vendor/herdr-manifests/qodercli.toml b/cmux-tui/vendor/herdr-manifests/qodercli.toml new file mode 100644 index 000000000000..51ca805ed77f --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/qodercli.toml @@ -0,0 +1,38 @@ +id = "qodercli" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["qoderclicn", "qoder", "qodercn"] + +[[rules]] +id = "confirmation_or_input_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["permission required"] }, + { contains = ["allow once or always?"] }, + { contains = ["asking user"] }, + { contains = ["enter your response"] }, + { contains = ["review your answers:"] }, + { contains = ["shell awaiting input"] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["(esc to cancel,"] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}'] diff --git a/cmux-tui/vendor/herdr-manifests/qwen.toml b/cmux-tui/vendor/herdr-manifests/qwen.toml new file mode 100644 index 000000000000..bbed23711b50 --- /dev/null +++ b/cmux-tui/vendor/herdr-manifests/qwen.toml @@ -0,0 +1,119 @@ +id = "qwen" +version = "2026.08.14.1" +min_engine_version = 2 +updated_at = "2026-08-14T00:00:00Z" +aliases = ["qwen-code", "qwen code"] + +# Qwen Code keeps its composer visible while responding, so the prompt box is +# not idle evidence by itself. Its status-prefixed terminal titles are the +# primary locale-independent signals when ui.showStatusInTitle is enabled. +# Exact screen fallbacks cover built-in locales and narrow terminals. OSC 9;4 +# is supplemental because Qwen emits it only while a tool is executing and +# only in terminals that advertise progress support. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1200 +region = "osc_title" +visible_blocker = true +regex = ['^\x{2733}\x{FE0E}? '] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +regex = ['^\x{25D0}\x{FE0E}? '] + +[[rules]] +id = "waiting_for_confirmation" +state = "blocked" +priority = 1000 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = ['^\s*⠏\s+.*\.\.\.\s*$'] +any = [ + { contains = ["Waiting for user confirmation..."] }, + { contains = ["等待用户确认..."] }, + { contains = ["等待用戶確認..."] }, + { contains = ["Warten auf Benutzerbestätigung..."] }, + { contains = ["En attente de la confirmation de l'utilisateur..."] }, + { contains = ["ユーザーの確認を待っています..."] }, + { contains = ["Aguardando confirmação do usuário..."] }, + { contains = ["Ожидание подтверждения от пользователя..."] }, + { contains = ["Esperant la confirmació de l'usuari..."] }, +] + +[[rules]] +id = "tool_confirmation" +state = "blocked" +priority = 990 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["yes, allow once"] +any = [ + { contains = ["apply this change?"] }, + { contains = ["allow execution of:"] }, + { contains = ["allow execution of mcp tool"] }, + { contains = ["do you want to proceed?"] }, + { contains = ["shell command execution"] }, +] + +[[rules]] +id = "question_dialog" +state = "blocked" +priority = 980 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = [ + '^\s*[❯›]\s*(?:\[(?: |✓)\]\s*)?\d+\.\s+', + '^\s*↑/↓\s*:.*(?:Enter|Return)\s*:', +] + +[[rules]] +id = "folder_trust_dialog" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "narrow_cancel_hint_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*\(\d+(?:m(?:\s+\d+s)?|s)\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "osc_tool_progress_working" +state = "working" +priority = 850 +region = "osc_progress" +visible_working = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(30)" +visible_idle = true +line_regex = ['^\s*>\s*(?:type\s*)?.*$'] +any = [ + { contains = ["type your message"] }, + { contains = ["your message or @path/to/file"] }, + { contains = ["@path/to/file"] }, + { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] }, +] From 0f668dffac49cbf2c0dea643be76a1866cf9615a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:04:57 -0700 Subject: [PATCH 3/9] fix(tui): lock screen detector dependency --- cmux-tui/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index bb1f5e8a0d04..6e76a27f4a86 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -854,6 +854,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tungstenite", + "toml", "uds_windows", "url", "wait-timeout", From e6389bfab5c0943fafd8eb6c47adc46bef57988d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:08:13 -0700 Subject: [PATCH 4/9] style(tui): format detector integration --- cmux-tui/crates/cmux-tui-core/src/mux.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 611cb95c0375..4a6e6a198277 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -5911,7 +5911,10 @@ impl Mux { } /// Returns the ephemeral provider identity detected for one terminal. - pub(crate) fn detected_agent_for_terminal(&self, terminal_id: &TerminalPublicId) -> Option { + pub(crate) fn detected_agent_for_terminal( + &self, + terminal_id: &TerminalPublicId, + ) -> Option { self.detected_agent_by_terminal.lock().unwrap().get(terminal_id).cloned() } @@ -5924,7 +5927,8 @@ impl Mux { ) { let Ok(terminal_id) = TerminalPublicId::parse(&emission.terminal_id) else { return }; let Some(surface) = self.resource_surface_for_terminal(&terminal_id) else { return }; - let Ok(record) = self.report_agent(surface, emission.state, AgentSource::Detected, None) else { + let Ok(record) = self.report_agent(surface, emission.state, AgentSource::Detected, None) + else { return; }; if emission.state == AgentState::Done { From 1209a7d556f105f5a2c083267f86bb50b6a5446f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:24:23 -0700 Subject: [PATCH 5/9] fix(tui): resolve foreground agent identity --- cmux-tui/crates/cmux-tui-core/src/mux.rs | 3 +- cmux-tui/crates/cmux-tui-core/src/platform.rs | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 4a6e6a198277..7127a4f8502f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -10122,13 +10122,14 @@ impl Mux { .or_else(|| { state_snapshot.terminal_catalog.get(&terminal_id).map(|surface| surface.id) })?; + let agent = self.detected_agent_for_terminal(&terminal_id); Some(AgentRecord { surface: representative, terminal_id, state: record.state, source: record.source, session: record.session, - agent: self.detected_agent_for_terminal(&terminal_id), + agent, updated_at_ms: record.updated_at_ms, }) }) diff --git a/cmux-tui/crates/cmux-tui-core/src/platform.rs b/cmux-tui/crates/cmux-tui-core/src/platform.rs index eac05801d847..69186d540738 100644 --- a/cmux-tui/crates/cmux-tui-core/src/platform.rs +++ b/cmux-tui/crates/cmux-tui-core/src/platform.rs @@ -988,6 +988,48 @@ pub fn foreground_cwd(pid: u32) -> Option { process_cwd(foreground_process_group(pid)?) } +/// Executable name of a terminal's live foreground process group leader. +/// Returns `None` when the leader is gone, the child has no controlling +/// terminal, or the platform denies the lookup. +pub fn foreground_process_name(pid: u32) -> Option { + process_name(foreground_process_group(pid)?) +} + +#[cfg(target_os = "linux")] +fn process_name(pid: u32) -> Option { + // argv[0]'s basename beats /proc//comm: comm truncates to 15 bytes + // and wrapper launchers can exec with a meaningful argv[0]. + let argv0 = std::fs::read(format!("/proc/{pid}/cmdline")).ok().and_then(|cmdline| { + let argv0 = cmdline.split(|byte| *byte == 0).next()?; + let argv0 = std::str::from_utf8(argv0).ok()?.trim(); + (!argv0.is_empty()).then(|| argv0.to_string()) + }); + argv0.or_else(|| { + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let comm = comm.trim(); + (!comm.is_empty()).then(|| comm.to_string()) + }) +} + +#[cfg(target_os = "macos")] +fn process_name(pid: u32) -> Option { + let pid = libc::c_int::try_from(pid).ok()?; + let mut path = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: proc_pidpath writes at most `path.len()` bytes and returns + // the written byte count (0 on failure). + let written = unsafe { libc::proc_pidpath(pid, path.as_mut_ptr().cast(), path.len() as u32) }; + if written <= 0 { + return None; + } + let path = std::str::from_utf8(&path[..written as usize]).ok()?; + (!path.is_empty()).then(|| path.to_string()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_name(_pid: u32) -> Option { + None +} + #[cfg(target_os = "linux")] fn foreground_process_group(pid: u32) -> Option { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; From e1493aad5e34c9213767f8a85e4a25a5433b0ac7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:34:59 -0700 Subject: [PATCH 6/9] fix(tui): keep detector helpers test-only --- cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs | 1 + cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs b/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs index 89405dd9abc9..114e4ca16173 100644 --- a/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs +++ b/cmux-tui/crates/cmux-tui-core/src/screen_detect/manifest.rs @@ -298,6 +298,7 @@ impl ManifestSet { Ok(Self { manifests }) } + #[cfg(test)] pub(crate) fn manifests(&self) -> impl Iterator { self.manifests.iter() } diff --git a/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs b/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs index b5b4e8242477..d9869f27fe8e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs +++ b/cmux-tui/crates/cmux-tui-core/src/screen_detect/mod.rs @@ -31,9 +31,6 @@ pub(crate) const QUIESCENCE_DEBOUNCE_MS: u64 = 300; /// phase it exists to report. pub(crate) const MAX_EVAL_INTERVAL_MS: u64 = 1_000; -/// The `native_event` value screen-detection journal events carry. -pub(crate) const SCREEN_DETECT_NATIVE_EVENT: &str = "ScreenDetect"; - /// One state transition the scanner must journal. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ScreenDetectEmission { @@ -102,6 +99,7 @@ impl ScreenDetectTracker { /// True when this terminal previously journaled a screen-derived state /// that has not been closed out by an exit emission. + #[cfg(test)] pub(crate) fn has_live_emission(&self, terminal_id: &str) -> bool { self.terminals.get(terminal_id).is_some_and(|entry| entry.emitted.is_some()) } From 29b31e683e127be5331848284c7b6ce9c775b678 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:18:35 -0700 Subject: [PATCH 7/9] fix(ui): guard optional agent icon panel --- Sources/Workspace+PanelLifecycle.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index cc439e477223..6fece76e62ec 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -346,7 +346,9 @@ extension Workspace { refreshTrackedAgentPorts() } if didChange { - syncTerminalTabAgentIconAsset(forPanelId: ownedPanelId ?? panelId) + if let changedPanelId = ownedPanelId ?? panelId { + syncTerminalTabAgentIconAsset(forPanelId: changedPanelId) + } } return didChange } From d1bfbbb1492e6f19b97adbca22cf12d0c9324367 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:43:08 -0700 Subject: [PATCH 8/9] fix(cloud): infer agent icons from terminal titles --- .../Surfaces/SurfaceResource+AgentIcon.swift | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/Surfaces/SurfaceResource+AgentIcon.swift b/Sources/Surfaces/SurfaceResource+AgentIcon.swift index 87bb7d1a9e5d..fdf0c910027f 100644 --- a/Sources/Surfaces/SurfaceResource+AgentIcon.swift +++ b/Sources/Surfaces/SurfaceResource+AgentIcon.swift @@ -5,14 +5,37 @@ extension SurfaceResource { /// Report provenance and user-controlled terminal titles are not provider IDs. var terminalAgentIconAssetName: String? { guard kind == .terminal, lifecycle != .exited, - let badge = agent, badge.state != "done", - let identity = (badge.agent ?? badge.source)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !identity.isEmpty, - !["hook", "socket", "detected", "plugin", "unknown"].contains(identity) else { return nil } - return CmuxTaskManagerCodingAgentDefinition.builtIns.first { definition in - definition.id == identity - || definition.launchKinds.contains(identity) - || definition.directBasenames.contains(identity) - }?.assetName + let badge = agent, badge.state != "done" else { return nil } + + let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns + let identities = [badge.agent, badge.source] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty && !["hook", "socket", "detected", "plugin", "unknown"].contains($0) } + if let asset = identities.lazy.compactMap({ identity in + definitions.first { definition in + definition.id == identity + || definition.launchKinds.contains(identity) + || definition.directBasenames.contains(identity) + }?.assetName + }).first { + return asset + } + + // Older remote daemons report hook provenance without the provider + // identity. Their terminal title still carries the user-facing agent + // name, so use it as a compatibility fallback until the daemon is + // upgraded to emit `agent`. + let titleTokens = title + .split(whereSeparator: { $0.isWhitespace || $0 == "✳" }) + .map { String($0).trimmingCharacters(in: .punctuationCharacters).lowercased() } + .filter { !$0.isEmpty } + return titleTokens.lazy.compactMap { token in + definitions.first { definition in + definition.id == token + || definition.displayName.lowercased().split(separator: " ").contains { $0 == token } + || definition.launchKinds.contains(token) + || definition.directBasenames.contains(token) + }?.assetName + }.first } } From 1f25c6bba0eda24bb5180bd8f724dcf5d8acaa74 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:03:58 -0700 Subject: [PATCH 9/9] fix(tui): publish hook provider identity --- cmux-tui/crates/cmux-tui-core/src/mux.rs | 33 +++++++++++++++++-- .../src/mux/public_projections.rs | 5 +++ .../public_projection_store.rs | 5 +++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 7186e81d7d30..db9788f12bca 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -1231,6 +1231,24 @@ fn agent_hook_notification( Some((format!("{agent} {verb}"), body, level)) } +fn agent_provider_identity(ingress: &crate::JournalIngress) -> Option { + ingress + .payload + .get("normalized") + .and_then(|normalized| normalized.get("agent_type")) + .and_then(Value::as_str) + .or_else(|| { + ingress + .payload + .get("adapter") + .and_then(|adapter| adapter.get("id")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) +} + #[derive(Debug, Clone)] pub struct AgentRecord { pub surface: SurfaceId, @@ -1387,6 +1405,7 @@ struct TerminalAgentRecord { state: AgentState, source: AgentSource, session: Option, + agent: Option, updated_at_ms: u64, } @@ -5871,6 +5890,7 @@ impl Mux { Some(marker), true, Some(DurableHookReport { state: hook_state, journal_sequence: sequence }), + agent_provider_identity(ingress), )?; fences.insert( terminal_id.clone(), @@ -9751,7 +9771,7 @@ impl Mux { source: AgentSource, session: Option, ) -> anyhow::Result { - self.report_agent_with_sequence_lock(surface, state, source, session, false, None) + self.report_agent_with_sequence_lock(surface, state, source, session, false, None, None) } fn report_agent_with_sequence_lock( @@ -9762,6 +9782,7 @@ impl Mux { session: Option, sequence_lock_held: bool, hook: Option, + agent: Option, ) -> anyhow::Result { let mutation = WorkspaceMutation::new( format!("raw-agent-{}", crate::workspace_registry::new_uuid_v4()), @@ -9949,12 +9970,14 @@ impl Mux { && source == AgentSource::Socket && !effective_hook_state.is_some_and(|state| state.ended) }); + let agent = agent.or_else(|| records.get(&terminal_id).and_then(|record| record.agent.clone())); let record = match records.get(&terminal_id) { Some(existing) if socket_report_ignored => existing.clone(), _ => TerminalAgentRecord { state: agent_state, source, session: source_session, + agent, updated_at_ms: now, }, }; @@ -9976,6 +9999,7 @@ impl Mux { "source":record.source.as_str(), "updated_at_ms":record.updated_at_ms.to_string(), "source_session":persisted_source_session.or(record.session.clone()), + "agent":record.agent, }); let mut public_value = value.clone(); public_value["source_session"] = serde_json::json!(record.session); @@ -10031,7 +10055,7 @@ impl Mux { state: record.state, source: record.source, session: record.session, - agent: None, + agent: record.agent, updated_at_ms: record.updated_at_ms, }; if !commit.replayed { @@ -23314,7 +23338,7 @@ mod tests { "claude", "UserPromptSubmit", Some(&terminal_id.to_string()), - serde_json::json!({}), + serde_json::json!({"agent_type":"claude"}), ) .unwrap(); @@ -23324,6 +23348,9 @@ mod tests { mux.apply_agent_hook_record(&hook, 1).unwrap(); assert_eq!(mux.list_agents(Some(surface.id), None)[0].state, AgentState::Working); + assert_eq!(mux.list_agents(Some(surface.id), None)[0].agent.as_deref(), Some("claude")); + let snapshot = crate::resource_api::public_session_snapshot(&mux).unwrap(); + assert_eq!(snapshot["agents"][0]["agent"], serde_json::json!("claude")); assert_eq!( mux.agent_hook_fences.lock().unwrap().get(&terminal_id).map(|fence| fence.sequence), Some(1) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs index 35c99ce8d59a..37d989501793 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs @@ -119,6 +119,7 @@ pub(super) fn restore_public_projections( state, source: agent_source(&agent.source)?, session: (!internal_marker).then_some(agent.source_session).flatten(), + agent: agent.agent, updated_at_ms: agent.updated_at_ms, }, ); @@ -237,6 +238,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: None, + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -319,6 +321,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: None, + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -341,6 +344,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: Some("cmux-hook-sequence:12".into()), + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -363,6 +367,7 @@ mod tests { source: "socket".into(), updated_at_ms: 3, source_session: Some("socket-session".into()), + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs index 3060ba9d44b5..11e01c6dba82 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs @@ -45,6 +45,7 @@ pub struct RegistryAgentProjection { pub source: String, pub updated_at_ms: u64, pub source_session: Option, + pub agent: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,6 +66,7 @@ impl RegistryAgentProjection { "source": self.source, "updated_at_ms": self.updated_at_ms.to_string(), "source_session": self.source_session, + "agent": self.agent, }) } } @@ -130,6 +132,8 @@ struct StoredAgent { updated_at_ms: WireDecimal, source_session: Option, #[serde(default)] + agent: Option, + #[serde(default)] extra: Option>, } @@ -426,6 +430,7 @@ impl WorkspaceRegistry { source: stored.source.as_str().to_string(), updated_at_ms: stored.updated_at_ms.get(), source_session: stored.source_session, + agent: stored.agent, }); } agents.reverse();