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:
@@ -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
|
||||
}
|
||||
}
|
||||
+153
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
@@ -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 })
|
||||
}
|
||||
}
|
||||
+1
@@ -35,6 +35,7 @@ extension TerminalSurface {
|
||||
surfaceHost: view,
|
||||
surfaceController: self,
|
||||
terminalLifecycleID: terminalLifecycleId,
|
||||
titleOverride: agentPanelTitle,
|
||||
rendererMailboxDidDrain: { surfaceID in
|
||||
Task { @MainActor in
|
||||
rendererRealization.scheduleRendererPresentationRepair(surfaceID: surfaceID)
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user