Repository navigation
feat: auto-name workspaces from directory + agent tab title enhancement #2852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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] { | ||||||||||||||||
|
Comment on lines
+4631
to
+4633
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This triggers repeated Prompt for AI agents
Suggested change
|
||||||||||||||||
| enhanceAgentTitle(tabId: tabId, panelId: panelId, ttyName: ttyName) | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+4631
to
+4635
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A minimal fix is to bail out early if the title is already in enhanced form for this panel: // 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],
!(tab.panelTitles[panelId]?.hasPrefix(title + ": ") ?? false) {
enhanceAgentTitle(tabId: tabId, panelId: panelId, ttyName: ttyName)
}This stops re-detection once an enhanced title is already in place and only re-runs when the raw agent name reappears after a session change. |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// 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) | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Async agent title enhancement can apply stale results and overwrite newer panel title state because completion lacks title/TTY consistency checks. Prompt for AI agents |
||||||||||||||||
| if self.selectedTabId == tabId && tab.focusedPanelId == panelId { | ||||||||||||||||
| self.updateWindowTitle(for: tab) | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+4630
to
4654
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revalidate the title/TTY before applying the async enhancement. Lines 4644-4648 only re-check 🧵 Suggested fix- enhanceAgentTitle(tabId: tabId, panelId: panelId, ttyName: ttyName)
+ enhanceAgentTitle(
+ tabId: tabId,
+ panelId: panelId,
+ ttyName: ttyName,
+ expectedBaseTitle: title
+ )
}
}
/// Asynchronously detect the agent's task from process arguments and update the panel title.
- private func enhanceAgentTitle(tabId: UUID, panelId: UUID, ttyName: String) {
+ private func enhanceAgentTitle(
+ tabId: UUID,
+ panelId: UUID,
+ ttyName: String,
+ expectedBaseTitle: 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.panelCustomTitles[panelId] == nil,
+ tab.surfaceTTYNames[panelId] == ttyName,
+ tab.panelTitles[panelId] == expectedBaseTitle else { return }
_ = tab.updatePanelTitle(panelId: panelId, title: enhanced)
if self.selectedTabId == tabId && tab.focusedPanelId == panelId {
self.updateWindowTitle(for: tab)
}
}
}🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| func focusedSurfaceTitleDidChange(tabId: UUID) { | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<String> = [ | ||||||
| "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 } | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Missing positive-value guards before the foreground check. When Prompt for AI agents
Suggested change
|
||||||
| 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) | ||||||
| } | ||||||
|
|
||||||
|
Comment on lines
+32
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
let foreground = snapshots.filter { $0.pgid > 0 && $0.tpgid > 0 && $0.pgid == $0.tpgid } |
||||||
| // 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..<i], encoding: .utf8) else { return nil } | ||||||
| args.append(arg) | ||||||
| while i < bytes.count, bytes[i] == 0 { i += 1 } | ||||||
| } | ||||||
| return args.count == argc ? args : nil | ||||||
| } | ||||||
|
|
||||||
| // MARK: - Task description extraction | ||||||
|
|
||||||
| /// Extract a human-readable task description from the agent's command-line arguments. | ||||||
| /// Returns `nil` if no meaningful description can be determined (e.g. interactive mode). | ||||||
| private static func extractTaskDescription(from args: [String], agent: String) -> 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 <prompt>" and "opencode run <prompt>", 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 | ||||||
| } | ||||||
|
Comment on lines
+127
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subcommand-only agent invocations are misclassified as task text. For Proposed fix- 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
- }
+ if (agent == "codex" || agent == "opencode"), let sub = tail.first?.lowercased() {
+ if sub == "exec" || sub == "run" {
+ positional = Array(tail.dropFirst())
+ } else {
+ positional = tail
+ }
+ } else {
+ positional = tail
+ }🤖 Prompt for AI Agents |
||||||
| } 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<String> = [ | ||||||
| "--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 | ||||||
| } | ||||||
| } | ||||||
|
Comment on lines
+88
to
+165
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both implementations are functionally identical (just variable names differ). If the kernel procargs parsing logic ever needs a fix, it would need to be applied in two places. Consider extracting a shared |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Localize the new default workspace title.
Line 2003 introduces a new bare UI string, so this path will stay English in localized builds. Please route the fallback through
String(localized:defaultValue:)and add the key toResources/Localizable.xcstrings.🌐 Suggested fix
🤖 Prompt for AI Agents