diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1fddf..5267bdd19790 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; }; A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; }; + A5001547 /* TerminalAgentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001546 /* TerminalAgentDetector.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; @@ -220,6 +221,7 @@ A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = ""; }; A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; + A5001546 /* TerminalAgentDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAgentDetector.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -477,6 +479,7 @@ A5001541 /* PortScanner.swift */, A5001544 /* TerminalImageTransfer.swift */, A5001545 /* TerminalSSHSessionDetector.swift */, + A5001546 /* TerminalAgentDetector.swift */, A5001225 /* SocketControlSettings.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, @@ -803,6 +806,7 @@ A5001540 /* PortScanner.swift in Sources */, A5001542 /* TerminalImageTransfer.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, + A5001547 /* TerminalAgentDetector.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index dc57ca829fad..4a14cb1b0e61 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1991,8 +1991,19 @@ class TabManager: ObservableObject { let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 + // Derive workspace title: explicit title > directory basename > "Terminal N" + let derivedTitle: String = { + if let title { return title } + if let dir = explicitWorkingDirectory { + let basename = URL(fileURLWithPath: dir).lastPathComponent + if !basename.isEmpty, basename != "/" { + return basename + } + } + return "Terminal \(nextTabCount)" + }() let newWorkspace = makeWorkspaceForCreation( - title: title ?? "Terminal \(nextTabCount)", + title: derivedTitle, workingDirectory: workingDirectory, portOrdinal: ordinal, configTemplate: inheritedConfig, @@ -4615,6 +4626,31 @@ class TabManager: ObservableObject { if selectedTabId == tabId && tab.focusedPanelId == panelId { updateWindowTitle(for: tab) } + + // If the title matches a known agent binary, try to enhance it with task info + if TerminalAgentDetector.isKnownAgentName(title), + tab.panelCustomTitles[panelId] == nil, + let ttyName = tab.surfaceTTYNames[panelId] { + enhanceAgentTitle(tabId: tabId, panelId: panelId, ttyName: ttyName) + } + } + + /// Asynchronously detect the agent's task from process arguments and update the panel title. + private func enhanceAgentTitle(tabId: UUID, panelId: UUID, ttyName: String) { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let agent = TerminalAgentDetector.detect(forTTY: ttyName), + let task = agent.taskDescription, !task.isEmpty else { return } + let enhanced = "\(agent.executableName): \(task)" + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.tabs.first(where: { $0.id == tabId }), + tab.panelCustomTitles[panelId] == nil else { return } + _ = tab.updatePanelTitle(panelId: panelId, title: enhanced) + if self.selectedTabId == tabId && tab.focusedPanelId == panelId { + self.updateWindowTitle(for: tab) + } + } + } } func focusedSurfaceTitleDidChange(tabId: UUID) { diff --git a/Sources/TerminalAgentDetector.swift b/Sources/TerminalAgentDetector.swift new file mode 100644 index 000000000000..00ce187b9a8b --- /dev/null +++ b/Sources/TerminalAgentDetector.swift @@ -0,0 +1,165 @@ +import Foundation +import Darwin + +/// Detects AI coding agent processes running in a terminal and extracts +/// their task descriptions from command-line arguments. +enum TerminalAgentDetector { + + struct DetectedAgent { + let executableName: String // e.g. "claude", "codex" + let taskDescription: String? // extracted from process args + let pid: Int32 + } + + /// Known agent binary names (matched case-insensitively against ucomm from ps). + static let knownAgentBinaries: Set = [ + "claude", "codex", "gemini", "opencode", "aider", "ft-claude", + ] + + /// Quick check whether a terminal title matches a known agent binary name. + static func isKnownAgentName(_ name: String) -> Bool { + knownAgentBinaries.contains(name.lowercased()) + } + + /// Detect a foreground agent process on the given TTY device. + /// Returns `nil` if no known agent is in the foreground process group. + static func detect(forTTY ttyName: String) -> DetectedAgent? { + let snapshots = processSnapshots(forTTY: ttyName) + // Find foreground processes (pgid == tpgid) that are known agents + let foreground = snapshots.filter { $0.pgid == $0.tpgid } + guard let match = foreground.first(where: { knownAgentBinaries.contains($0.executableName) }) else { + return nil + } + let args = commandLineArguments(forPID: match.pid) + let task = args.flatMap { extractTaskDescription(from: $0, agent: match.executableName) } + return DetectedAgent(executableName: match.executableName, taskDescription: task, pid: match.pid) + } + + // MARK: - Process enumeration (same pattern as TerminalSSHSessionDetector) + + private struct ProcessSnapshot { + let pid: Int32 + let pgid: Int32 + let tpgid: Int32 + let executableName: String + } + + private static let psPath = "/bin/ps" + + private static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: psPath) + process.arguments = ["-ww", "-t", ttyName, "-o", "pid=,pgid=,tpgid=,ucomm="] + process.standardInput = FileHandle.nullDevice + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { try process.run() } catch { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, + let output = String(data: data, encoding: .utf8) else { return [] } + + return output.split(separator: "\n").compactMap { line in + let parts = line.split(maxSplits: 3, whereSeparator: \.isWhitespace) + guard parts.count == 4, + let pid = Int32(parts[0]), + let pgid = Int32(parts[1]), + let tpgid = Int32(parts[2]) else { return nil } + return ProcessSnapshot( + pid: pid, pgid: pgid, tpgid: tpgid, + executableName: String(parts[3]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + ) + } + } + + // MARK: - Command-line argument reading (KERN_PROCARGS2) + + private static func commandLineArguments(forPID pid: Int32) -> [String]? { + var mib = [CTL_KERN, KERN_PROCARGS2, pid] + var size: size_t = 0 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 4 else { return nil } + + var buffer = [UInt8](repeating: 0, count: size) + let ok = buffer.withUnsafeMutableBytes { raw in + sysctl(&mib, u_int(mib.count), raw.baseAddress, &size, nil, 0) == 0 + } + guard ok else { return nil } + return parseKernProcArgs(Array(buffer.prefix(Int(size)))) + } + + private static func parseKernProcArgs(_ bytes: [UInt8]) -> [String]? { + guard bytes.count > 4 else { return nil } + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { $0.copyBytes(from: bytes.prefix(4)) } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return nil } + + // Skip past argc + executable path + padding nulls + var i = 4 + while i < bytes.count, bytes[i] != 0 { i += 1 } + while i < bytes.count, bytes[i] == 0 { i += 1 } + + var args: [String] = [] + while i < bytes.count, args.count < argc { + let start = i + while i < bytes.count, bytes[i] != 0 { i += 1 } + guard let arg = String(bytes: bytes[start.. String? { + // args[0] is the executable path; positional args start from args[1..] + guard args.count > 1 else { return nil } + let tail = Array(args.dropFirst()) + + // For "codex exec " and "opencode run ", skip the subcommand + let positional: [String] + if (agent == "codex" || agent == "opencode"), tail.count > 1 { + let sub = tail[0].lowercased() + if sub == "exec" || sub == "run" { + positional = Array(tail.dropFirst()) + } else { + positional = tail + } + } else { + positional = tail + } + + // Find first non-flag argument (doesn't start with -) + // Also skip known flag values (the argument after a flag that takes a value) + let flagsWithValue: Set = [ + "--model", "-m", "--settings", "--agent", "-c", + "--append-system-prompt", "--append-system-prompt-file", + "--system-prompt", "--system-prompt-file", + "--prompt-file", + ] + + var skipNext = false + for arg in positional { + if skipNext { skipNext = false; continue } + if arg.hasPrefix("-") { + if flagsWithValue.contains(arg) { skipNext = true } + continue + } + // Found a positional argument — this is likely the prompt/task + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + // Truncate to keep tab names readable + if trimmed.count > 60 { + return String(trimmed.prefix(57)) + "..." + } + return trimmed + } + return nil + } +}