Merge pull request #10198 from manaflow-ai/issue-10189-claude-teams-panel-path-title

Fix Claude Teams panel PATH and agent names
This commit is contained in:
Austin Wang
2026-08-17 16:39:22 -07:00
committed by GitHub
17 changed files with 890 additions and 121 deletions
+18 -13
View File
@@ -77,26 +77,31 @@ extension CMUXCLI {
/// the pane exits before the real command runs; that is why Claude Code
/// 2.1.183 teammates never opened a split pane (issue #6447).
///
/// Every command is run through `/bin/sh -c '<command>'`, so Ghostty execs a
/// shell rather than a builtin/expression/assignment-prefix. The whole command
/// is single-quoted, so it round-trips verbatim regardless of operators or
/// quoting there is no attempt to classify which commands "need" a shell,
/// which was unreliable (tmux shell-commands can hide operators with no
/// surrounding whitespace). Commands that are already a shell invocation (e.g.
/// OMO's `/bin/sh -c ""`) are simply run through one more shell, which execs
/// straight into them.
/// Every command is run through `/bin/sh -lc '<command>'`, so Ghostty execs a
/// login shell rather than a builtin/expression/assignment-prefix. The `-l`
/// is important: Ghostty's `exec -l` only changes argv[0], and does not make
/// macOS `/bin/sh` read `/etc/profile` when it is given a non-interactive `-c`
/// command. The login shell therefore runs `path_helper` and restores the
/// user's full login PATH before the command starts (issue #10189). The whole
/// command is single-quoted, so it round-trips verbatim regardless of
/// operators or quoting there is no attempt to classify which commands
/// "need" a shell, which was unreliable (tmux shell-commands can hide
/// operators with no surrounding whitespace). Commands that are already a
/// shell invocation (e.g. OMO's `/bin/sh -c ""`) are simply run through one
/// more shell, which execs straight into them.
///
/// A POSIX shell (`/bin/sh`) is used deliberately rather than the user's
/// `$SHELL`: the commands being wrapped are POSIX `sh` syntax (Claude Code's
/// `cd && env `, and the no-command fallback `exec ${SHELL:-/bin/sh} -l`),
/// and `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter
/// expansion or `NAME=value` command prefixes. `/bin/sh` is always present and
/// runs the bodies correctly for every user. `-l` is not passed (`/bin/sh`
/// does not take it); on macOS Ghostty already supplies a login-style argv0.
/// and `csh`/`tcsh` cannot parse `${VAR:-default}` parameter expansion or
/// `NAME=value` command prefixes. `/bin/sh` is always present and runs the
/// bodies correctly for every user; its login mode is a shell-independent way to
/// invoke macOS `path_helper` without asking the user's shell to parse a
/// POSIX command body.
func tmuxShellInvokedStartCommand(_ command: String) -> String {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return command }
return "/bin/sh -c \(tmuxShellQuote(trimmed))"
return "/bin/sh -lc \(tmuxShellQuote(trimmed))"
}
/// Like `tmuxShellInvokedStartCommand`, but first exports `prependEnv` inside
@@ -0,0 +1,430 @@
import Foundation
/// Extracts the stable identity that Claude Code supplies for a team member.
///
/// Claude's terminal title is the agent type (for example,
/// `general-purpose`), while the launch argv contains the human-selected
/// `--agent-name`. The resolver keeps that metadata parsing independent from
/// the terminal/UI layers so every panel-spawn path can apply the same
/// name-first, type-fallback policy.
public struct AgentPanelTitleResolver: Sendable {
/// Creates a stateless agent-panel title resolver.
public init() {}
/// The two Claude team identity fields that can be present in a launch.
public struct Metadata: Equatable, Sendable {
/// The human-selected teammate name, when supplied.
public let name: String?
/// Claude's role/type, used when no name was supplied.
public let type: String?
/// The title cmux should present for this agent panel.
public var displayTitle: String? { name ?? type }
init(name: String?, type: String?) {
self.name = name
self.type = type
}
}
private static let maximumCommandNesting = 4
private static let maximumTitleLength = 128
private static let commandSeparators: Set<String> = ["&&", "||", ";", "|", "|&", "&"]
private static let shellNames: Set<String> = [
"ash", "bash", "csh", "dash", "fish", "ksh", "mksh", "nu", "pwsh", "sh", "tcsh", "zsh"
]
private static let transparentCommandPrefixes: Set<String> = ["command", "exec"]
/// Creates metadata from a process-style argv, including argv[0].
///
/// The executable basename must be `claude`, unless the argv carries the
/// complete teammate identity envelope used by Claude's versioned native
/// executables. Similarly shaped name/type flags on an unrelated command
/// are not treated as panel identity.
///
/// Values may use either `--agent-name value` or
/// `--agent-name=value` spelling. Once `--` is encountered, the remaining
/// positional prompt is not treated as launch metadata.
public func metadata(fromArguments arguments: [String]) -> Metadata? {
Self.metadata(inArgumentVector: arguments)
}
/// Creates metadata from a shell command captured for a terminal surface.
///
/// The command may contain `cd`, `env`, assignments, shell operators, or a
/// nested `/bin/sh -lc ''` wrapper. The parser intentionally does not
/// execute or expand any shell text.
public func metadata(fromCommand command: String) -> Metadata? {
let tokens = Self.shellTokens(command)
guard !tokens.isEmpty else { return nil }
return Self.metadata(fromTokens: tokens, depth: 0)
}
/// Returns the preferred title from a set of launch-command candidates.
/// A name from any candidate wins over every type, which keeps a wrapped
/// command's fallback metadata from masking the actual teammate name.
public func title(fromCommands commands: [String]) -> String? {
var name: String?
var type: String?
for command in commands {
guard let metadata = metadata(fromCommand: command) else { continue }
name = name ?? metadata.name
type = type ?? metadata.type
}
return name ?? type
}
private static func metadata(fromTokens tokens: [String], depth: Int) -> Metadata? {
guard depth <= maximumCommandNesting else { return nil }
var name: String?
var type: String?
for segment in commandSegments(tokens) {
guard !segment.isEmpty else { continue }
if let nested = nestedShellCommand(in: segment),
let nestedMetadata = metadata(
fromCommand: nested,
depth: depth + 1
) {
name = name ?? nestedMetadata.name
type = type ?? nestedMetadata.type
}
let arguments = commandArguments(in: segment)
guard !arguments.isEmpty,
let segmentMetadata = metadata(inArgumentVector: arguments) else {
continue
}
name = name ?? segmentMetadata.name
type = type ?? segmentMetadata.type
}
guard name != nil || type != nil else { return nil }
return Metadata(name: name, type: type)
}
private static func metadata(fromCommand command: String, depth: Int) -> Metadata? {
let tokens = shellTokens(command)
guard !tokens.isEmpty else { return nil }
return metadata(fromTokens: tokens, depth: depth)
}
private static func metadata(inArgumentVector arguments: [String]) -> Metadata? {
guard arguments.count > 1 else { return nil }
var name: String?
var type: String?
var hasAgentID = false
var hasTeamName = false
var hasParentSessionID = false
var index = 1
while index < arguments.count {
let token = arguments[index]
if token == "--" { break }
if let value = optionValue(
token: token,
option: "--agent-name",
following: arguments.dropFirst(index + 1).first
) {
name = name ?? normalizedTitle(value.value)
index += value.consumed
continue
}
if let value = optionValue(
token: token,
option: "--agent-type",
following: arguments.dropFirst(index + 1).first
) {
type = type ?? normalizedTitle(value.value)
index += value.consumed
continue
}
if let value = optionValue(
token: token,
option: "--agent-id",
following: arguments.dropFirst(index + 1).first
) {
hasAgentID = hasAgentID || normalizedTitle(value.value) != nil
index += value.consumed
continue
}
if let value = optionValue(
token: token,
option: "--team-name",
following: arguments.dropFirst(index + 1).first
) {
hasTeamName = hasTeamName || normalizedTitle(value.value) != nil
index += value.consumed
continue
}
if let value = optionValue(
token: token,
option: "--parent-session-id",
following: arguments.dropFirst(index + 1).first
) {
hasParentSessionID = hasParentSessionID || normalizedTitle(value.value) != nil
index += value.consumed
continue
}
index += 1
}
let hasTeammateIdentityEnvelope = hasAgentID && hasTeamName && hasParentSessionID
let hasValidatedClaudeExecutable = commandName(arguments[0]) == "claude"
|| (hasTeammateIdentityEnvelope && isVersionedNativeClaudeExecutable(arguments[0]))
guard hasValidatedClaudeExecutable else { return nil }
guard name != nil || type != nil else { return nil }
return Metadata(name: name, type: type)
}
private static func isVersionedNativeClaudeExecutable(_ token: String) -> Bool {
let components = token.split(separator: "/", omittingEmptySubsequences: true)
guard components.count >= 3,
components.dropLast().suffix(2).elementsEqual(["claude", "versions"]),
let version = components.last else {
return false
}
let versionComponents = version.split(separator: ".", omittingEmptySubsequences: false)
return versionComponents.count >= 3
&& versionComponents.allSatisfy { component in
!component.isEmpty && component.allSatisfy(\.isNumber)
}
}
private static func optionValue(
token: String,
option: String,
following: String?
) -> (value: String, consumed: Int)? {
if token == option {
guard let following,
!following.hasPrefix("-") else {
return nil
}
return (following, 2)
}
let prefix = option + "="
guard token.hasPrefix(prefix) else { return nil }
let value = String(token.dropFirst(prefix.count))
guard !value.isEmpty else { return nil }
return (value, 1)
}
private static func normalizedTitle(_ raw: String) -> String? {
let pieces = raw.split(whereSeparator: { $0.isWhitespace || $0.isNewline })
guard !pieces.isEmpty else { return nil }
let value = pieces.joined(separator: " ")
guard !value.hasPrefix("-") else { return nil }
return String(value.prefix(maximumTitleLength))
}
private static func commandSegments(_ tokens: [String]) -> [[String]] {
var segments: [[String]] = []
var current: [String] = []
for token in tokens {
if commandSeparators.contains(token) {
if !current.isEmpty { segments.append(current) }
current.removeAll(keepingCapacity: true)
} else {
current.append(token)
}
}
if !current.isEmpty { segments.append(current) }
return segments
}
/// Removes shell-only prefixes and returns the argv beginning at the real
/// command. `env` assignments are deliberately skipped so flags in the
/// command itself remain at stable argv positions.
private static func commandArguments(in segment: [String]) -> [String] {
var index = 0
while index < segment.count {
let token = segment[index]
if isAssignment(token) {
index += 1
continue
}
let basename = commandName(token)
if basename == "cd" {
return []
}
if basename == "env" {
index += 1
while index < segment.count {
let option = segment[index]
if option == "--" {
index += 1
break
}
if isAssignment(option) {
index += 1
continue
}
if option.hasPrefix("-") {
index += 1
if option == "-u" || option == "--unset" {
index += 1
}
continue
}
break
}
continue
}
if transparentCommandPrefixes.contains(basename) {
index += 1
while index < segment.count {
let option = segment[index]
if option == "--" {
index += 1
break
}
guard option.hasPrefix("-") else { break }
index += 1
if basename == "exec", option == "-a", index < segment.count {
index += 1
}
}
continue
}
return Array(segment[index...])
}
return []
}
private static func nestedShellCommand(in segment: [String]) -> String? {
let invocation = commandArguments(in: segment)
guard !invocation.isEmpty else { return nil }
let shellArguments: [String]
if commandName(invocation[0]) == "login" {
// Ghostty invokes `/usr/bin/login -flp <user> /bin/bash ` on
// macOS. Only inspect a path-valued shell belonging to an actual
// login invocation; a `/bin/sh -c ` string passed to `echo` (or
// another ordinary process) must remain opaque data.
guard let shellIndex = invocation.indices.dropFirst().first(where: { index in
invocation[index].contains("/") && shellNames.contains(commandName(invocation[index]))
}) else {
return nil
}
shellArguments = Array(invocation[shellIndex...])
} else {
shellArguments = invocation
}
guard let shell = shellArguments.first,
shellNames.contains(commandName(shell)) else {
return nil
}
var index = 1
while index < shellArguments.count {
let token = shellArguments[index]
if token == "--" {
index += 1
continue
}
if token == "-c" || (!token.hasPrefix("--") && token.hasPrefix("-") && token.contains("c")) {
let commandIndex = index + 1
guard commandIndex < shellArguments.count else { return nil }
return shellArguments[commandIndex]
}
index += 1
}
return nil
}
private static func commandName(_ token: String) -> String {
token.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? token
}
private static func isAssignment(_ token: String) -> Bool {
guard let equals = token.firstIndex(of: "="), equals != token.startIndex else {
return false
}
let name = token[..<equals]
guard let first = name.first, first == "_" || first.isLetter else { return false }
return name.allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber }
}
/// Tokenizes the small POSIX shell subset used by tmux start commands.
/// Quotes are removed, but their contents remain one token; no expansion
/// or command substitution is performed.
private static func shellTokens(_ command: String) -> [String] {
var tokens: [String] = []
var current = ""
var inSingleQuote = false
var inDoubleQuote = false
var escaping = false
let characters = Array(command)
var index = 0
func flush() {
guard !current.isEmpty else { return }
tokens.append(current)
current.removeAll(keepingCapacity: true)
}
while index < characters.count {
let character = characters[index]
if escaping {
current.append(character)
escaping = false
index += 1
continue
}
if character == "\\" && !inSingleQuote {
escaping = true
index += 1
continue
}
if character == "'" && !inDoubleQuote {
inSingleQuote.toggle()
index += 1
continue
}
if character == "\"" && !inSingleQuote {
inDoubleQuote.toggle()
index += 1
continue
}
if !inSingleQuote, !inDoubleQuote {
if character.isWhitespace || character.isNewline {
flush()
index += 1
continue
}
if character == ";" {
flush()
tokens.append(";")
index += 1
continue
}
if character == "&" || character == "|" {
flush()
if index + 1 < characters.count, characters[index + 1] == character {
tokens.append(String([character, character]))
index += 2
} else if character == "|" && index + 1 < characters.count,
characters[index + 1] == "&" {
tokens.append("|&")
index += 2
} else {
tokens.append(String(character))
index += 1
}
continue
}
}
current.append(character)
index += 1
}
if escaping { current.append("\\") }
flush()
return tokens
}
}
@@ -0,0 +1,153 @@
import Testing
import CMUXAgentLaunch
@Suite("Agent panel title resolver")
struct AgentPanelTitleResolverTests {
private let resolver = AgentPanelTitleResolver()
@Test("agent name wins over agent type")
func nameWinsOverType() {
let metadata = resolver.metadata(fromArguments: [
"/opt/claude",
"--agent-name", "Testare-B",
"--agent-color", "green",
"--agent-type", "general-purpose",
])
#expect(metadata?.name == "Testare-B")
#expect(metadata?.type == "general-purpose")
#expect(metadata?.displayTitle == "Testare-B")
}
@Test("agent type is the fallback when no name is present")
func typeFallsBackWhenNameIsMissing() {
let metadata = resolver.metadata(fromArguments: [
"claude",
"--agent-type=general-purpose",
])
#expect(metadata?.name == nil)
#expect(metadata?.type == "general-purpose")
#expect(metadata?.displayTitle == "general-purpose")
}
@Test("shell command wrappers and env assignments are skipped")
func shellCommandWrappersAreSkipped() {
let metadata = resolver.metadata(fromCommand: """
cd '/tmp/work' && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name 'Alice A' --agent-type general-purpose
""")
#expect(metadata?.displayTitle == "Alice A")
}
@Test("versioned native Claude teammate executables use the agent name")
func versionedNativeClaudeTeammateUsesAgentName() {
let title = resolver.title(fromCommands: [
"""
cd /tmp/work && env CLAUDECODE=1 /Users/austin/.local/share/claude/versions/2.1.233 \
--agent-id PathScout@session-87b88f27 \
--agent-name PathScout \
--team-name session-87b88f27 \
--agent-color blue \
--parent-session-id f9b4d8eb-1069-4776-bd4b-ff1da62f2561
""",
])
#expect(title == "PathScout")
}
@Test("nested login shell wrappers are inspected without executing them")
func nestedLoginShellWrapperIsInspected() {
let metadata = resolver.metadata(fromCommand: """
exec -l /bin/sh -lc 'cd /tmp/work && env PATH=/opt/bin /opt/claude --agent-name Nested --agent-type general-purpose'
""")
#expect(metadata?.displayTitle == "Nested")
}
@Test("Ghostty login and noprofile wrappers still reveal the agent name")
func ghosttyLoginWrapperIsInspected() {
let command = #"login -flp austin /bin/bash --noprofile --norc -c 'exec -l /bin/sh -c "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-name Testare-B --agent-color green --agent-type general-purpose"'"#
#expect(resolver.title(fromCommands: [command]) == "Testare-B")
}
@Test("a name in a later command wins over an earlier type fallback")
func nameWinsAcrossLaunchCommandCandidates() {
#expect(
resolver.title(fromCommands: [
"claude --agent-type general-purpose",
"claude --agent-name Testare-D",
]) == "Testare-D"
)
}
@Test("equals and separate value forms are both accepted")
func acceptsEqualsAndSeparateValueForms() {
let metadata = resolver.metadata(fromArguments: [
"claude",
"--agent-type", "general-purpose",
"--agent-name=Testare-C",
])
#expect(metadata?.displayTitle == "Testare-C")
}
@Test("malformed name falls back to a valid type")
func malformedNameFallsBackToType() {
let metadata = resolver.metadata(fromArguments: [
"claude",
"--agent-name", "--agent-type",
"general-purpose",
])
#expect(metadata?.name == nil)
#expect(metadata?.displayTitle == "general-purpose")
}
@Test("prompt arguments after the option terminator are ignored")
func optionTerminatorStopsMetadataScan() {
let metadata = resolver.metadata(fromArguments: [
"claude",
"--",
"user prompt mentioning --agent-name", "not-a-panel-name",
])
#expect(metadata == nil)
}
@Test("non-Claude commands with agent-shaped arguments are ignored")
func nonClaudeArgumentVectorIsIgnored() {
let metadata = resolver.metadata(fromArguments: [
"python3",
"worker.py",
"--agent-name", "ordinary-process",
"--agent-type", "batch-job",
])
#expect(metadata == nil)
}
@Test("a complete teammate envelope does not identify an unrelated executable")
func nonClaudeIdentityEnvelopeIsIgnored() {
let metadata = resolver.metadata(fromArguments: [
"/opt/workers/2.1.233",
"--agent-id", "Wrong@team",
"--agent-name", "Wrong",
"--agent-type", "batch-job",
"--team-name", "team",
"--parent-session-id", "f9b4d8eb-1069-4776-bd4b-ff1da62f2561",
])
#expect(metadata == nil)
}
@Test("a shell command mentioned as ordinary data is not inspected")
func shellCommandArgumentIsNotMistakenForAWrapper() {
let title = resolver.title(fromCommands: [
"echo /bin/sh -lc 'claude --agent-name Not-An-Agent --agent-type general-purpose'",
])
#expect(title == nil)
}
}
@@ -0,0 +1,11 @@
internal import CMUXAgentLaunch
extension TerminalSurface {
/// The stable teammate title encoded in this surface's launch commands.
public var agentPanelTitle: String? {
AgentPanelTitleResolver().title(fromCommands: [
tmuxStartCommand,
initialCommand,
].compactMap { $0 })
}
}
@@ -35,6 +35,7 @@ extension TerminalSurface {
surfaceHost: view,
surfaceController: self,
terminalLifecycleID: terminalLifecycleId,
titleOverride: agentPanelTitle,
rendererMailboxDidDrain: { surfaceID in
Task { @MainActor in
rendererRealization.scheduleRendererPresentationRepair(surfaceID: surfaceID)
@@ -63,9 +63,15 @@ public final class GhosttySurfaceCallbackContext {
/// The stable identity of the surface this context was created for.
public let surfaceId: UUID
/// The model identity used to authenticate callbacks from this runtime.
public let sourceSurfaceIdentifier: ObjectIdentifier
/// The terminal process generation that owns this callback context.
public let terminalLifecycleID: UUID
/// The immutable title that overrides runtime OSC title updates, if any.
public let titleOverride: String?
/// Runs after renderer activity consumes an armed presentation repair.
private let rendererMailboxDidDrainHandler: @Sendable (UUID) -> Void
@@ -87,6 +93,8 @@ public final class GhosttySurfaceCallbackContext {
/// - surfaceController: The surface model owning the runtime surface.
/// - terminalLifecycleID: The terminal process generation that owns the
/// native runtime surface.
/// - titleOverride: An immutable title derived from the runtime's launch
/// metadata, or `nil` to use Ghostty's OSC title updates.
/// - rendererMailboxDidDrain: Called with only the stable surface id after
/// an armed repair observes renderer activity following a mailbox drain.
/// - maximumRuntimeClipboardRequests: Maximum simultaneous native
@@ -95,13 +103,16 @@ public final class GhosttySurfaceCallbackContext {
surfaceHost: any TerminalSurfaceHosting,
surfaceController: any TerminalSurfaceControlling,
terminalLifecycleID: UUID,
titleOverride: String? = nil,
rendererMailboxDidDrain: @escaping @Sendable (UUID) -> Void = { _ in },
maximumRuntimeClipboardRequests: Int = 32
) {
self.surfaceHost = surfaceHost
self.surfaceController = surfaceController
self.surfaceId = surfaceController.surfaceId
self.sourceSurfaceIdentifier = ObjectIdentifier(surfaceController)
self.terminalLifecycleID = terminalLifecycleID
self.titleOverride = titleOverride
self.rendererMailboxDidDrainHandler = rendererMailboxDidDrain
self.maximumRuntimeClipboardRequests = max(
0,
@@ -0,0 +1,62 @@
import Foundation
import Testing
import CmuxTerminalCore
import GhosttyKit
private final class CallbackMetadataSurfaceController: TerminalSurfaceControlling {
let surfaceId: UUID
let owningTabId: UUID
var runtimeSurfacePointer: ghostty_surface_t?
init(
surfaceId: UUID = UUID(),
owningTabId: UUID = UUID(),
runtimeSurfacePointer: ghostty_surface_t? = nil
) {
self.surfaceId = surfaceId
self.owningTabId = owningTabId
self.runtimeSurfacePointer = runtimeSurfacePointer
}
}
private final class CallbackMetadataSurfaceHost: TerminalSurfaceHosting {
var hostedTabId: UUID?
var attachedSurfaceController: (any TerminalSurfaceControlling)?
}
@Suite struct GhosttySurfaceCallbackMetadataTests {
@Test func capturesImmutableTitleAndSourceIdentity() {
let controller = CallbackMetadataSurfaceController()
let sourceSurfaceIdentifier = ObjectIdentifier(controller)
let context = GhosttySurfaceCallbackContext(
surfaceHost: CallbackMetadataSurfaceHost(),
surfaceController: controller,
terminalLifecycleID: UUID(),
titleOverride: "Testare-B"
)
#expect(context.sourceSurfaceIdentifier == sourceSurfaceIdentifier)
#expect(context.titleOverride == "Testare-B")
}
@Test func replacementRuntimeKeepsItsOwnTitleSnapshot() {
let controller = CallbackMetadataSurfaceController()
let host = CallbackMetadataSurfaceHost()
let first = GhosttySurfaceCallbackContext(
surfaceHost: host,
surfaceController: controller,
terminalLifecycleID: UUID(),
titleOverride: "Testare-A"
)
let replacement = GhosttySurfaceCallbackContext(
surfaceHost: host,
surfaceController: controller,
terminalLifecycleID: UUID(),
titleOverride: "Testare-B"
)
#expect(first.titleOverride == "Testare-A")
#expect(replacement.titleOverride == "Testare-B")
#expect(first.terminalLifecycleID != replacement.terminalLifecycleID)
}
}
+7 -7
View File
@@ -3225,15 +3225,15 @@ class GhosttyApp {
case GHOSTTY_ACTION_SET_TITLE:
let title = action.action.set_title.title
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId,
let sourceSurface = surfaceView.terminalSurface,
let terminalLifecycleID = callbackContext?.terminalLifecycleID {
if let callbackContext,
let tabId = callbackTabId {
surfaceView.titleUpdateIngress.submit(
tabId: tabId,
surfaceId: sourceSurface.id,
sourceSurface: sourceSurface,
terminalLifecycleID: terminalLifecycleID,
title: title
surfaceId: callbackContext.surfaceId,
sourceSurfaceIdentifier: callbackContext.sourceSurfaceIdentifier,
terminalLifecycleID: callbackContext.terminalLifecycleID,
title: title,
titleOverride: callbackContext.titleOverride
)
}
return true
+10 -5
View File
@@ -15,7 +15,6 @@ final class GhosttyTitleUpdateIngress {
/// Ghostty serializes action callbacks for a view; no other context reads
/// or writes this duplicate-rejection snapshot.
private var lastSubmittedUpdate: GhosttyTitleUpdate?
init(
center: NotificationCenter = .default,
titleChurnFilter: TerminalTitleChurnFilter = TerminalTitleChurnFilter(),
@@ -73,18 +72,24 @@ final class GhosttyTitleUpdateIngress {
func submit(
tabId: UUID,
surfaceId: UUID,
sourceSurface: AnyObject,
sourceSurfaceIdentifier: ObjectIdentifier,
terminalLifecycleID: UUID,
title: String
title: String,
titleOverride: String? = nil
) -> Bool {
guard let stableTitle = titleChurnFilter.stableTitle(for: title) else {
let stableTitle: String
if let titleOverride {
stableTitle = titleOverride
} else if let churnStableTitle = titleChurnFilter.stableTitle(for: title) {
stableTitle = churnStableTitle
} else {
return false
}
let update = GhosttyTitleUpdate(
tabId: tabId,
surfaceId: surfaceId,
title: stableTitle,
sourceSurfaceIdentifier: ObjectIdentifier(sourceSurface),
sourceSurfaceIdentifier: sourceSurfaceIdentifier,
terminalLifecycleID: terminalLifecycleID,
attachmentGeneration: attachmentGeneration.loadRelaxed()
)
+1
View File
@@ -151,6 +151,7 @@ final class TerminalPanel: Panel, ObservableObject {
self.id = surface.id
self.workspaceId = workspaceId
self.surface = surface
self.title = surface.agentPanelTitle ?? "Terminal"
// Subscribe to surface's search state changes
surface.$searchState
.sink { [weak self] state in
+8
View File
@@ -1226,6 +1226,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
468110000000000000000009 /* GhosttyTitleUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46811000000000000000000A /* GhosttyTitleUpdate.swift */; };
468110000000000000000003 /* GhosttyTitleUpdateDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468110000000000000000004 /* GhosttyTitleUpdateDispatcher.swift */; };
46811000000000000000000B /* GhosttyTitleUpdateIngress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46811000000000000000000C /* GhosttyTitleUpdateIngress.swift */; };
A10190000000000000000001 /* GhosttyTitleUpdateIngressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10190000000000000000002 /* GhosttyTitleUpdateIngressTests.swift */; };
468140000000000000000009 /* GhosttyTitleUpdateSurfaceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46814000000000000000000A /* GhosttyTitleUpdateSurfaceKey.swift */; };
46814000000000000000000B /* GhosttyTitleUpdateSurfaceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46814000000000000000000C /* GhosttyTitleUpdateSurfaceState.swift */; };
FE001107 /* GitFileStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001007 /* GitFileStatus.swift */; };
@@ -2251,6 +2252,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
C7A507000000000000004529 /* TaskManagerViewSnapshotBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A507000000000000004530 /* TaskManagerViewSnapshotBoundaryTests.swift */; };
C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; };
7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D00F7490D00F7490D00F /* TaskVMInfoMemoryPressureFootprintSampler.swift */; };
C10190010000000000000002 /* TerminalAgentPanelInitialTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */; };
46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; };
8362A0030000000000000003 /* TerminalCallerTTYBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8362A0040000000000000004 /* TerminalCallerTTYBinding.swift */; };
8362A0010000000000000001 /* TerminalCallerTTYResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8362A0020000000000000002 /* TerminalCallerTTYResolver.swift */; };
@@ -4013,6 +4015,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
46811000000000000000000A /* GhosttyTitleUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdate.swift; sourceTree = "<group>"; };
468110000000000000000004 /* GhosttyTitleUpdateDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateDispatcher.swift; sourceTree = "<group>"; };
46811000000000000000000C /* GhosttyTitleUpdateIngress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateIngress.swift; sourceTree = "<group>"; };
A10190000000000000000002 /* GhosttyTitleUpdateIngressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateIngressTests.swift; sourceTree = "<group>"; };
46814000000000000000000A /* GhosttyTitleUpdateSurfaceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateSurfaceKey.swift; sourceTree = "<group>"; };
46814000000000000000000C /* GhosttyTitleUpdateSurfaceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTitleUpdateSurfaceState.swift; sourceTree = "<group>"; };
FE001007 /* GitFileStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitFileStatus.swift; sourceTree = "<group>"; };
@@ -5020,6 +5023,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C7A507000000000000004530 /* TaskManagerViewSnapshotBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerViewSnapshotBoundaryTests.swift; sourceTree = "<group>"; };
C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = "<group>"; };
7490D00F7490D00F7490D00F /* TaskVMInfoMemoryPressureFootprintSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TaskVMInfoMemoryPressureFootprintSampler.swift; sourceTree = "<group>"; };
C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAgentPanelInitialTitleTests.swift; sourceTree = "<group>"; };
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = "<group>"; };
8362A0040000000000000004 /* TerminalCallerTTYBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCallerTTYBinding.swift; sourceTree = "<group>"; };
8362A0020000000000000002 /* TerminalCallerTTYResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCallerTTYResolver.swift; sourceTree = "<group>"; };
@@ -8077,6 +8081,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
71F8ED92A4B55D34BE6A0668 /* WorkspaceSplitStartupCommandTests.swift */,
6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */,
6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */,
C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */,
A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */,
A91C0D110000000000000001 /* CompletedRestoredAgentForkAvailabilityTests.swift */,
A37946EDBC5645DCB867DE82 /* CompletedRestoredAgentGenerationTests.swift */,
@@ -8432,6 +8437,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
468100000000000000000002 /* CmuxNotificationHookCacheTests.swift */,
468160000000000000000002 /* KeyboardShortcutSettingsObserverTests.swift */,
468130000000000000000002 /* GhosttyDesktopNotificationIngressTests.swift */,
A10190000000000000000002 /* GhosttyTitleUpdateIngressTests.swift */,
468100000000000000000004 /* TypingHotPathRegressionTests.swift */,
C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */,
A5FB1206 /* CmuxConfigWorkspaceActionTests.swift */,
@@ -11281,6 +11287,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
816400000000000000000003 /* GhosttyScrollViewTests.swift in Sources */,
C13519000000000000000003 /* GhosttyTerminalStartupEnvironmentTests.swift in Sources */,
D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */,
A10190000000000000000001 /* GhosttyTitleUpdateIngressTests.swift in Sources */,
8561A0018561A0018561A001 /* GlobalSearchInputOwnershipTests.swift in Sources */,
D5AAB5856FF095F610EB565A /* GlobalSearchShortcutBehaviorTests.swift in Sources */,
8561A0068561A0068561A006 /* GlobalSearchShortcutPersistencePolicyTests.swift in Sources */,
@@ -11560,6 +11567,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */,
C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */,
C7A507000000000000004529 /* TaskManagerViewSnapshotBoundaryTests.swift in Sources */,
C10190010000000000000002 /* TerminalAgentPanelInitialTitleTests.swift in Sources */,
46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */,
8362B0010000000000000001 /* TerminalCallerTTYResolverTests.swift in Sources */,
E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */,
@@ -197,18 +197,19 @@ import Testing
/// expression like `cd && claude` makes it try to exec the `cd` builtin as a
/// binary, the pane exits immediately, and the teammate never gets a visible pane
/// (it falls back to in-process). The fix runs every tmux respawn shell-command
/// through a POSIX shell (/bin/sh) so Ghostty execs the shell, not the expression.
/// through a POSIX login shell (/bin/sh -lc) so profile/path_helper runs
/// before Ghostty execs the shell, not the expression.
/// `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD
/// detection keep reporting it.
@Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws {
let shellPrefix = "/bin/sh -c "
let shellPrefix = "/bin/sh -lc "
// Claude Code teammate command: spaced `cd && claude` shell expression.
let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice"
let teammateResult = try respawnPaneForwardedCommand(teammate)
#expect(
teammateResult.command.hasPrefix(shellPrefix),
"teammate command must run through /bin/sh -c, got: \(teammateResult.command)"
"teammate command must run through /bin/sh -lc, got: \(teammateResult.command)"
)
#expect(
teammateResult.command.contains(teammate),
@@ -272,8 +273,8 @@ import Testing
extraEnvironment: ["CMUX_CLAUDE_TEAMS_SANDBOXED": "1"]
)
#expect(
inTeams.command.hasPrefix("/bin/sh -c "),
"claude-teams respawn must still run through /bin/sh -c, got: \(inTeams.command)"
inTeams.command.hasPrefix("/bin/sh -lc "),
"claude-teams respawn must still run through /bin/sh -lc, got: \(inTeams.command)"
)
#expect(
inTeams.command.contains("export CLAUDE_CODE_SANDBOXED="),
@@ -351,7 +352,7 @@ import Testing
)
#expect(inTeams.startCommand == teammate)
let unchangedCommand = "/bin/sh -c '\(teammate)'"
let unchangedCommand = "/bin/sh -lc '\(teammate)'"
let inOMO = try respawnPaneForwardedCommand(teammate)
#expect(
inOMO.command == unchangedCommand,
@@ -0,0 +1,133 @@
import Foundation
import Testing
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@Suite("Ghostty title update ingress")
@MainActor
struct GhosttyTitleUpdateIngressTests {
@Test func duplicateCallbackTitleIsRejectedBeforeEnqueue() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let sourceIdentifier = ObjectIdentifier(NSObject())
let terminalLifecycleID = UUID()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
#expect(!ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
#expect(ingress.submit(
tabId: UUID(),
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
}
@Test func spinnerFramesCollapseBeforeAsyncStreamEnqueue() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let sourceIdentifier = ObjectIdentifier(NSObject())
let terminalLifecycleID = UUID()
let frames = ["", "", "", "", "", "", "", "", "", ""]
for (index, frame) in frames.enumerated() {
let submitted = ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "\(frame) pnpm install"
)
#expect(submitted == (index == 0))
}
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "⠋ pnpm run build"
))
}
@Test func retiringAttachmentAllowsItsFirstRepeatedTitleAfterReattach() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let sourceIdentifier = ObjectIdentifier(NSObject())
let terminalLifecycleID = UUID()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
ingress.retireCurrentAttachment()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurfaceIdentifier: sourceIdentifier,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
}
@Test func callbackTitleOverrideReplacesTheOscTitle() async throws {
let center = NotificationCenter()
let scheduler = TitleScheduleRecorder()
let ingress = GhosttyTitleUpdateIngress(
center: center,
schedule: scheduler.schedule(_:action:)
)
let (changes, continuation) = AsyncStream<GhosttyTitleChange>.makeStream()
let observer = center.addObserver(
forName: .ghosttyDidSetTitle,
object: nil,
queue: nil
) { notification in
guard let change = GhosttyTitleChange(notification: notification) else {
return
}
continuation.yield(change)
}
defer {
center.removeObserver(observer)
continuation.finish()
}
var iterator = changes.makeAsyncIterator()
#expect(ingress.submit(
tabId: UUID(),
surfaceId: UUID(),
sourceSurfaceIdentifier: ObjectIdentifier(NSObject()),
terminalLifecycleID: UUID(),
title: "",
titleOverride: "Testare-B"
))
await scheduler.awaitFirstSchedule()
await scheduler.fire()
let change = try #require(await iterator.next())
#expect(change.title == "Testare-B")
}
}
@@ -154,7 +154,7 @@ struct TabManagerTitleUpdateStalenessTests {
#expect(ingress.submit(
tabId: workspace.id,
surfaceId: panelId,
sourceSurface: retainedSurface,
sourceSurfaceIdentifier: ObjectIdentifier(retainedSurface),
terminalLifecycleID: callbackLifecycleID,
title: staleTitle
))
@@ -0,0 +1,33 @@
import Foundation
import Testing
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
@Suite(.serialized)
struct TerminalAgentPanelInitialTitleTests {
@Test
func versionedClaudeTeammateNameIsTheInitialPanelTitle() {
let command = """
cd /tmp/work && env CLAUDECODE=1 /Users/austin/.local/share/claude/versions/2.1.233 \
--agent-id PathScout2@session-87b88f27 \
--agent-name PathScout2 \
--team-name session-87b88f27 \
--agent-color blue \
--agent-type general-purpose \
--parent-session-id f9b4d8eb-1069-4776-bd4b-ff1da62f2561
"""
let panel = TerminalPanel(
workspaceId: UUID(),
initialCommand: command,
runtimeSpawnPolicy: .heldForStartupRestoreAdmission
)
defer { panel.surface.teardownSurface() }
#expect(panel.displayTitle == "PathScout2")
}
}
@@ -378,92 +378,6 @@ struct GhosttyTitleUpdateDispatcherTests {
}
}
@Suite("Ghostty title update ingress")
@MainActor
struct GhosttyTitleUpdateIngressTests {
@Test func duplicateCallbackTitleIsRejectedBeforeEnqueue() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let source = NSObject()
let terminalLifecycleID = UUID()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
#expect(!ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
#expect(ingress.submit(
tabId: UUID(),
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
}
@Test func spinnerFramesCollapseBeforeAsyncStreamEnqueue() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let source = NSObject()
let terminalLifecycleID = UUID()
let frames = ["", "", "", "", "", "", "", "", "", ""]
for (index, frame) in frames.enumerated() {
let submitted = ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "\(frame) pnpm install"
)
#expect(submitted == (index == 0))
}
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "⠋ pnpm run build"
))
}
@Test func retiringAttachmentAllowsItsFirstRepeatedTitleAfterReattach() {
let ingress = GhosttyTitleUpdateIngress()
let tabId = UUID()
let surfaceId = UUID()
let source = NSObject()
let terminalLifecycleID = UUID()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
ingress.retireCurrentAttachment()
#expect(ingress.submit(
tabId: tabId,
surfaceId: surfaceId,
sourceSurface: source,
terminalLifecycleID: terminalLifecycleID,
title: "stable"
))
}
}
@Suite("Right-sidebar mode shortcut matcher")
@MainActor
struct RightSidebarModeShortcutMatcherTests {
+4 -3
View File
@@ -35,11 +35,12 @@ ATTACH_COMMAND = (
def shell_wrapped(command: str) -> str:
"""Mirror CMUXCLI.tmuxShellInvokedStartCommand: respawn shell-commands are run
through a POSIX shell (`/bin/sh -c`) so Ghostty's macOS `exec -l <command>`
execs a shell rather than the raw expression (issue #6447). Quoting mirrors
through a POSIX login shell (`/bin/sh -lc`) so macOS profile/path_helper
restores the login PATH before Ghostty's `exec -l <command>` execs a shell
rather than the raw expression (issues #6447 and #10189). Quoting mirrors
tmuxShellQuote (single-quote, with embedded single quotes escaped)."""
quoted = "'" + command.replace("'", "'\"'\"'") + "'"
return "/bin/sh -c " + quoted
return "/bin/sh -lc " + quoted
class FakeCmuxState: