From 817f30fd9357d7305d89e0b2d8a5e1333419640c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:16:18 -0700 Subject: [PATCH 1/6] Add Claude session title regression tests --- cmuxTests/SessionIndexViewTests.swift | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index 0b80332ae958..bed15b52db5c 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -11,6 +11,26 @@ import XCTest @MainActor final class SessionIndexViewTests: XCTestCase { + func testClaudeLocalCommandCaveatDisplaysReadableTitle() { + let entry = makeEntry( + title: "Caveat: The messages below were generated by the user while running local commands." + ) + + XCTAssertEqual(entry.displayTitle, "Local command") + } + + func testClaudeSlashCommandMarkupDisplaysReadableTitle() { + let entry = makeEntry( + title: """ + /plugin + plugin + install codex@openai-codex + """ + ) + + XCTAssertEqual(entry.displayTitle, "/plugin install codex@openai-codex") + } + func testCurrentDirectorySetterDoesNotPublishEqualValue() { let store = SessionIndexStore() var emittedValues: [String?] = [] @@ -121,6 +141,21 @@ final class SessionIndexViewTests: XCTestCase { return SessionPopoverHarness(host: host, section: section, search: search, loadSnapshot: loadSnapshot) } + private func makeEntry(title: String) -> SessionEntry { + SessionEntry( + id: UUID().uuidString, + agent: .claude, + sessionId: UUID().uuidString, + title: title, + cwd: nil, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 0), + fileURL: nil, + specifics: .claude(model: nil, permissionMode: nil) + ) + } + private func pumpRunLoop() { RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } From f92c39ce39de5ed39961265e4b022bcdf7152843 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:19:49 -0700 Subject: [PATCH 2/6] Clean up Claude session titles --- Resources/Localizable.xcstrings | 17 ++++++++ Sources/SessionIndexStore.swift | 70 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b6d54dcb0116..aa5041ed9493 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -92846,6 +92846,23 @@ } } }, + "sessionIndex.localCommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルコマンド" + } + } + } + }, "sessionIndex.scope.thisFolder": { "extractionState": "manual", "localizations": { diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 6bb6793dbee8..d9b497b47529 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -110,12 +110,71 @@ struct SessionEntry: Identifiable, Hashable { var displayTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if agent == .claude { + if let title = Self.claudeDisplayTitle(from: trimmed) { + return title + } + if Self.isClaudeLocalCommandEnvelope(trimmed) { + return String(localized: "sessionIndex.localCommand", defaultValue: "Local command") + } + if Self.isClaudeSyntheticEnvelope(trimmed) { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled session") + } + } if trimmed.isEmpty { return String(localized: "sessionIndex.untitled", defaultValue: "Untitled session") } return trimmed } + static func claudeDisplayTitle(from raw: String, isMeta: Bool = false) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let commandTitle = claudeSlashCommandTitle(from: trimmed) { + return commandTitle + } + if isMeta || isClaudeSyntheticEnvelope(trimmed) { + return nil + } + return trimmed + } + + private static func claudeSlashCommandTitle(from raw: String) -> String? { + guard let command = claudeTagValue("command-name", in: raw) + ?? claudeTagValue("command-message", in: raw) else { + return nil + } + if let args = claudeTagValue("command-args", in: raw) { + return "\(command) \(args)" + } + return command + } + + private static func claudeTagValue(_ tag: String, in raw: String) -> String? { + let open = "<\(tag)>" + let close = "" + guard let start = raw.range(of: open), + let end = raw.range(of: close, range: start.upperBound.. Bool { + isClaudeLocalCommandEnvelope(raw) + || raw.hasPrefix("") + } + + private static func isClaudeLocalCommandEnvelope(_ raw: String) -> Bool { + raw.hasPrefix(" String { + value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + var cwdLabel: String? { guard let cwd, !cwd.isEmpty else { return nil } let home = NSHomeDirectory() @@ -657,6 +716,7 @@ final class SessionIndexStore: ObservableObject { for line in head.split(separator: "\n", omittingEmptySubsequences: true) { guard let data = line.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + let isMeta = (obj["isMeta"] as? Bool) ?? false if let cwdField = obj["cwd"] as? String, !cwdField.isEmpty { out.cwd = cwdField } @@ -675,13 +735,15 @@ final class SessionIndexStore: ObservableObject { (obj["type"] as? String) == "user", let message = obj["message"] as? [String: Any], (message["role"] as? String) == "user" { - if let content = message["content"] as? String, !content.isEmpty { - out.title = content + if let content = message["content"] as? String, + let title = SessionEntry.claudeDisplayTitle(from: content, isMeta: isMeta) { + out.title = title } else if let parts = message["content"] as? [[String: Any]] { for part in parts { if (part["type"] as? String) == "text", - let text = part["text"] as? String, !text.isEmpty { - out.title = text + let text = part["text"] as? String, + let title = SessionEntry.claudeDisplayTitle(from: text, isMeta: isMeta) { + out.title = title break } } From b1341f38295625be3f5a4d6e6edfb6cc830b382e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:24:56 -0700 Subject: [PATCH 3/6] Add Claude resume config regression test --- cmuxTests/SessionIndexViewTests.swift | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index bed15b52db5c..e7e3b83e5218 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -31,6 +31,19 @@ final class SessionIndexViewTests: XCTestCase { XCTAssertEqual(entry.displayTitle, "/plugin install codex@openai-codex") } + func testClaudeResumeCommandPinsConfigDirectoryFromFileURL() { + let entry = makeEntry( + sessionId: "claude-session-123", + title: "resume me", + fileURL: URL(fileURLWithPath: "/tmp/claude config/projects/-tmp/claude-session-123.jsonl") + ) + + XCTAssertEqual( + entry.resumeCommand, + "env CLAUDE_CONFIG_DIR='/tmp/claude config' claude --resume claude-session-123" + ) + } + func testCurrentDirectorySetterDoesNotPublishEqualValue() { let store = SessionIndexStore() var emittedValues: [String?] = [] @@ -141,17 +154,21 @@ final class SessionIndexViewTests: XCTestCase { return SessionPopoverHarness(host: host, section: section, search: search, loadSnapshot: loadSnapshot) } - private func makeEntry(title: String) -> SessionEntry { + private func makeEntry( + sessionId: String = UUID().uuidString, + title: String, + fileURL: URL? = nil + ) -> SessionEntry { SessionEntry( id: UUID().uuidString, agent: .claude, - sessionId: UUID().uuidString, + sessionId: sessionId, title: title, cwd: nil, gitBranch: nil, pullRequest: nil, modified: Date(timeIntervalSince1970: 0), - fileURL: nil, + fileURL: fileURL, specifics: .claude(model: nil, permissionMode: nil) ) } From 4a1bce5e6026eadba6c6fac79b7975ac7eb77b03 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:27:23 -0700 Subject: [PATCH 4/6] Pin Claude resume commands to config roots --- Sources/SessionIndexStore.swift | 184 ++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 34 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index d9b497b47529..23f1ca246467 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -71,7 +71,10 @@ struct SessionEntry: Identifiable, Hashable { if let permissionMode, !permissionMode.isEmpty { parts.append("--permission-mode \(Self.shellQuote(permissionMode))") } - return parts.joined(separator: " ") + return Self.withShellEnvironment( + claudeConfigDirectoryForResume.map { ["CLAUDE_CONFIG_DIR": $0] } ?? [:], + command: parts.joined(separator: " ") + ) case let .codex(model, approval, sandbox, effort): var parts = ["codex resume \(sessionId)"] if let model, !model.isEmpty { @@ -99,6 +102,32 @@ struct SessionEntry: Identifiable, Hashable { } } + private var claudeConfigDirectoryForResume: String? { + guard agent == .claude, + let fileURL, + let projectsIndex = fileURL.pathComponents.firstIndex(of: "projects"), + projectsIndex > 0 else { + return nil + } + let configComponents = Array(fileURL.pathComponents[.. String { + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(shellQuote(value))" } + guard !assignments.isEmpty else { return command } + return "env \(assignments.joined(separator: " ")) \(command)" + } + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. private static func shellQuote(_ value: String) -> String { if value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { @@ -709,6 +738,70 @@ final class SessionIndexStore: ObservableObject { var permissionMode: String? } + private struct ClaudeSessionRoot: Hashable { + let configDir: String + + var projectsRoot: String { + (configDir as NSString).appendingPathComponent("projects") + } + } + + nonisolated private static func claudeSessionRoots() -> [ClaudeSessionRoot] { + let fm = FileManager.default + var roots: [String] = [] + var seen: Set = [] + + func appendRoot(_ rawPath: String?, requireConfigured: Bool) { + guard let rawPath else { return } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let configDir = (trimmed as NSString).expandingTildeInPath + let standardized = (configDir as NSString).standardizingPath + let projectsRoot = (standardized as NSString).appendingPathComponent("projects") + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: projectsRoot, isDirectory: &isDirectory), + isDirectory.boolValue else { + return + } + if requireConfigured, !isLikelyConfiguredClaudeRoot(standardized) { + return + } + guard seen.insert(standardized).inserted else { return } + roots.append(standardized) + } + + let environmentConfigDir = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"] + appendRoot(environmentConfigDir, requireConfigured: false) + + let accountRoot = ("~/.codex-accounts/claude" as NSString).expandingTildeInPath + if let accountDirs = try? fm.contentsOfDirectory(atPath: accountRoot) { + for accountDir in accountDirs.sorted() { + appendRoot( + (accountRoot as NSString).appendingPathComponent(accountDir), + requireConfigured: true + ) + } + } + + appendRoot( + ("~/.claude" as NSString).expandingTildeInPath, + requireConfigured: !roots.isEmpty + ) + + return roots.map(ClaudeSessionRoot.init(configDir:)) + } + + nonisolated private static func isLikelyConfiguredClaudeRoot(_ configDir: String) -> Bool { + let configPath = (configDir as NSString).appendingPathComponent(".claude.json") + guard let data = FileManager.default.contents(atPath: configPath), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return obj["oauthAccount"] != nil + || obj["primaryApiKey"] != nil + || obj["apiKey"] != nil + } + nonisolated private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { var out = ClaudeParsed() out.cwd = decodeClaudeProjectDir(projectDir) @@ -822,6 +915,16 @@ final class SessionIndexStore: ObservableObject { return candidate } + nonisolated private static func claudeProjectDirName(for url: URL, projectsRoot: String) -> String { + let root = projectsRoot.hasSuffix("/") ? projectsRoot : projectsRoot + "/" + guard url.path.hasPrefix(root) else { + return url.deletingLastPathComponent().lastPathComponent + } + let relative = String(url.path.dropFirst(root.count)) + return relative.split(separator: "/", maxSplits: 1).first.map(String.init) + ?? url.deletingLastPathComponent().lastPathComponent + } + /// Inverse of `decodeClaudeProjectDir`. Used as a fast path: when filtering /// by cwd we can skip enumerating other project dirs entirely. nonisolated private static func encodeClaudeProjectDir(_ path: String) -> String { @@ -1196,8 +1299,8 @@ final class SessionIndexStore: ObservableObject { } /// Returns Claude session entries paginated by mtime desc. - /// - When `needle` is empty: fast path. Skips rg, enumerates `~/.claude/projects`, - /// takes the top `offset+limit` by mtime, parses metadata, returns the slice. + /// - When `needle` is empty: fast path. Skips rg, enumerates configured Claude + /// roots, takes the top `offset+limit` by mtime, parses metadata, returns the slice. /// - When `needle` is non-empty and rg is on PATH: rg pre-filters the candidate /// set; we only parse files that actually contain the needle. /// - When `needle` is non-empty and rg is missing/failed: falls back to the @@ -1205,7 +1308,8 @@ final class SessionIndexStore: ObservableObject { nonisolated private static func loadClaudeEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int ) async -> [SessionEntry] { - let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath + let roots = claudeSessionRoots() + guard !roots.isEmpty else { return [] } let fm = FileManager.default // Pre-filter via rg when we have a needle — rg is parallel, mmaps the @@ -1213,44 +1317,56 @@ final class SessionIndexStore: ObservableObject { // speeds the scan up and finds matches deeper in long transcripts. var rgFiltered = false var candidates: [(URL, Date, String)] = [] - if !needle.isEmpty, - let rgPaths = await ripgrepMatchingPaths(needle: needle, root: projectsRoot, fileGlob: "*.jsonl") { - rgFiltered = true - for url in rgPaths { - guard let attrs = try? fm.attributesOfItem(atPath: url.path), - let mtime = attrs[.modificationDate] as? Date else { continue } - let dirName = url.deletingLastPathComponent().lastPathComponent - candidates.append((url, mtime, dirName)) + if !needle.isEmpty { + for root in roots { + guard let rgPaths = await ripgrepMatchingPaths( + needle: needle, + root: root.projectsRoot, + fileGlob: "*.jsonl" + ) else { + continue + } + rgFiltered = true + for url in rgPaths { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let mtime = attrs[.modificationDate] as? Date else { continue } + let dirName = claudeProjectDirName(for: url, projectsRoot: root.projectsRoot) + candidates.append((url, mtime, dirName)) + } } } else if let cwdFilter { // Fast path: the project directory name encodes the cwd. We can skip // enumerating every other project entirely. let dirName = encodeClaudeProjectDir(cwdFilter) - let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue, - let contents = try? fm.contentsOfDirectory(atPath: dirPath) { - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), - let mtime = attrs[.modificationDate] as? Date else { continue } - candidates.append((url, mtime, dirName)) + for root in roots { + let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue, + let contents = try? fm.contentsOfDirectory(atPath: dirPath) { + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } } } } else { - guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } - for dirName in projectDirs { - let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } - guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), - let mtime = attrs[.modificationDate] as? Date else { continue } - candidates.append((url, mtime, dirName)) + for root in roots { + guard let projectDirs = try? fm.contentsOfDirectory(atPath: root.projectsRoot) else { continue } + for dirName in projectDirs { + let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } } } } From 4548060faef40bbd9329db9b0d1d28c6b299df37 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:33:07 -0700 Subject: [PATCH 5/6] Include default Claude sessions root --- Sources/SessionIndexStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 23f1ca246467..ecb13458f093 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -785,7 +785,7 @@ final class SessionIndexStore: ObservableObject { appendRoot( ("~/.claude" as NSString).expandingTildeInPath, - requireConfigured: !roots.isEmpty + requireConfigured: false ) return roots.map(ClaudeSessionRoot.init(configDir:)) From 2d959d31b3c70135e904672897898eecee23bad6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 00:45:51 -0700 Subject: [PATCH 6/6] Address Claude session review feedback --- Sources/SessionIndexStore.swift | 188 ++++++++++++++++++-------- cmuxTests/SessionIndexViewTests.swift | 41 +++++- 2 files changed, 174 insertions(+), 55 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index ecb13458f093..23193c815f7f 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -104,12 +104,15 @@ struct SessionEntry: Identifiable, Hashable { private var claudeConfigDirectoryForResume: String? { guard agent == .claude, - let fileURL, - let projectsIndex = fileURL.pathComponents.firstIndex(of: "projects"), + let fileURL else { + return nil + } + let pathComponents = fileURL.standardizedFileURL.pathComponents + guard let projectsIndex = pathComponents.lastIndex(of: "projects"), projectsIndex > 0 else { return nil } - let configComponents = Array(fileURL.pathComponents[.. String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if let commandTitle = claudeSlashCommandTitle(from: trimmed) { - return commandTitle - } if isMeta || isClaudeSyntheticEnvelope(trimmed) { return nil } + if let commandTitle = claudeSlashCommandTitle(from: trimmed) { + return commandTitle + } return trimmed } private static func claudeSlashCommandTitle(from raw: String) -> String? { - guard let command = claudeTagValue("command-name", in: raw) - ?? claudeTagValue("command-message", in: raw) else { - return nil + let commandName = claudeTagValue("command-name", in: raw) + let commandMessage = claudeTagValue("command-message", in: raw) + var parts: [String] = [] + if let commandName { + parts.append(commandName) + } + if let commandMessage, + !isDuplicateClaudeCommandMessage(commandMessage, commandName: commandName) { + parts.append(commandMessage) } if let args = claudeTagValue("command-args", in: raw) { - return "\(command) \(args)" + parts.append(args) } - return command + return parts.isEmpty ? nil : parts.joined(separator: " ") + } + + private static func isDuplicateClaudeCommandMessage(_ message: String, commandName: String?) -> Bool { + guard let commandName else { return false } + let commandWithoutSlash = commandName.hasPrefix("/") + ? String(commandName.dropFirst()) + : commandName + return message.caseInsensitiveCompare(commandName) == .orderedSame + || message.caseInsensitiveCompare(commandWithoutSlash) == .orderedSame } private static func claudeTagValue(_ tag: String, in raw: String) -> String? { @@ -746,6 +764,13 @@ final class SessionIndexStore: ObservableObject { } } + private struct ClaudeSessionCandidate: Sendable { + let url: URL + let mtime: Date + let dirName: String + let prefilteredByRipgrep: Bool + } + nonisolated private static func claudeSessionRoots() -> [ClaudeSessionRoot] { let fm = FileManager.default var roots: [String] = [] @@ -932,6 +957,54 @@ final class SessionIndexStore: ObservableObject { return path.replacingOccurrences(of: "/", with: "-") } + nonisolated private static func enumerateClaudeJSONLCandidates( + root: ClaudeSessionRoot, + cwdFilter: String?, + prefilteredByRipgrep: Bool + ) -> [ClaudeSessionCandidate] { + let fm = FileManager.default + var candidates: [ClaudeSessionCandidate] = [] + + func appendJSONLFiles(in dirPath: String, dirName: String) { + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { return } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append( + ClaudeSessionCandidate( + url: url, + mtime: mtime, + dirName: dirName, + prefilteredByRipgrep: prefilteredByRipgrep + ) + ) + } + } + + if let cwdFilter { + let dirName = encodeClaudeProjectDir(cwdFilter) + let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue { + appendJSONLFiles(in: dirPath, dirName: dirName) + } + return candidates + } + + guard let projectDirs = try? fm.contentsOfDirectory(atPath: root.projectsRoot) else { + return candidates + } + for dirName in projectDirs { + let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + appendJSONLFiles(in: dirPath, dirName: dirName) + } + return candidates + } + // MARK: Codex private struct CodexParsed { @@ -1315,8 +1388,7 @@ final class SessionIndexStore: ObservableObject { // Pre-filter via rg when we have a needle — rg is parallel, mmaps the // file, and scans the WHOLE file (not just our 128 KB head), so it both // speeds the scan up and finds matches deeper in long transcripts. - var rgFiltered = false - var candidates: [(URL, Date, String)] = [] + var candidates: [ClaudeSessionCandidate] = [] if !needle.isEmpty { for root in roots { guard let rgPaths = await ripgrepMatchingPaths( @@ -1324,53 +1396,53 @@ final class SessionIndexStore: ObservableObject { root: root.projectsRoot, fileGlob: "*.jsonl" ) else { + candidates.append( + contentsOf: enumerateClaudeJSONLCandidates( + root: root, + cwdFilter: cwdFilter, + prefilteredByRipgrep: false + ) + ) continue } - rgFiltered = true for url in rgPaths { guard let attrs = try? fm.attributesOfItem(atPath: url.path), let mtime = attrs[.modificationDate] as? Date else { continue } let dirName = claudeProjectDirName(for: url, projectsRoot: root.projectsRoot) - candidates.append((url, mtime, dirName)) + candidates.append( + ClaudeSessionCandidate( + url: url, + mtime: mtime, + dirName: dirName, + prefilteredByRipgrep: true + ) + ) } } } else if let cwdFilter { // Fast path: the project directory name encodes the cwd. We can skip // enumerating every other project entirely. - let dirName = encodeClaudeProjectDir(cwdFilter) for root in roots { - let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue, - let contents = try? fm.contentsOfDirectory(atPath: dirPath) { - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), - let mtime = attrs[.modificationDate] as? Date else { continue } - candidates.append((url, mtime, dirName)) - } - } + candidates.append( + contentsOf: enumerateClaudeJSONLCandidates( + root: root, + cwdFilter: cwdFilter, + prefilteredByRipgrep: false + ) + ) } } else { for root in roots { - guard let projectDirs = try? fm.contentsOfDirectory(atPath: root.projectsRoot) else { continue } - for dirName in projectDirs { - let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } - guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), - let mtime = attrs[.modificationDate] as? Date else { continue } - candidates.append((url, mtime, dirName)) - } - } + candidates.append( + contentsOf: enumerateClaudeJSONLCandidates( + root: root, + cwdFilter: nil, + prefilteredByRipgrep: false + ) + ) } } - candidates.sort { $0.1 > $1.1 } + candidates.sort { $0.mtime > $1.mtime } // Take a generous window of candidates to inspect in parallel. We need // enough to cover both targets and skipped files; we'll trim to @@ -1390,38 +1462,46 @@ final class SessionIndexStore: ObservableObject { of: (Int, SessionEntry?, Bool).self ) { group in for (idx, candidate) in workCandidates.enumerated() { - let (url, mtime, dirName) = candidate group.addTask { // Cache hit - if let cached = ClaudeMetadataCache.shared.get(url: url, mtime: mtime) { + let cached = ClaudeMetadataCache.shared.get(url: candidate.url, mtime: candidate.mtime) + if let cached, needle.isEmpty || candidate.prefilteredByRipgrep { if let cwdFilter, cached.cwd != cwdFilter { return (idx, nil, true) } return (idx, cached, true) } - let head = readFileHead(url: url, byteCap: headByteCap) - let tail = readFileTail(url: url, byteCap: tailByteCap) - if !needle.isEmpty && !rgFiltered { + let head = readFileHead(url: candidate.url, byteCap: headByteCap) + let tail = readFileTail(url: candidate.url, byteCap: tailByteCap) + if !needle.isEmpty && !candidate.prefilteredByRipgrep { let combined = head + "\n" + tail if combined.range(of: needle, options: [.caseInsensitive, .literal]) == nil { return (idx, nil, false) } } - let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) + if let cached { + if let cwdFilter, cached.cwd != cwdFilter { return (idx, nil, true) } + return (idx, cached, true) + } + let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: candidate.dirName) if let cwdFilter, parsed.cwd != cwdFilter { return (idx, nil, false) } - let sid = url.deletingPathExtension().lastPathComponent + let sid = candidate.url.deletingPathExtension().lastPathComponent let entry = SessionEntry( - id: "claude:" + url.path, + id: "claude:" + candidate.url.path, agent: .claude, sessionId: sid, title: parsed.title, cwd: parsed.cwd, gitBranch: parsed.branch, pullRequest: parsed.pr, - modified: mtime, - fileURL: url, + modified: candidate.mtime, + fileURL: candidate.url, specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) ) if needle.isEmpty { - ClaudeMetadataCache.shared.put(url: url, mtime: mtime, entry: entry) + ClaudeMetadataCache.shared.put( + url: candidate.url, + mtime: candidate.mtime, + entry: entry + ) } return (idx, entry, false) } diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index e7e3b83e5218..5c2560e3a423 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -16,7 +16,10 @@ final class SessionIndexViewTests: XCTestCase { title: "Caveat: The messages below were generated by the user while running local commands." ) - XCTAssertEqual(entry.displayTitle, "Local command") + XCTAssertEqual( + entry.displayTitle, + String(localized: "sessionIndex.localCommand", defaultValue: "Local command") + ) } func testClaudeSlashCommandMarkupDisplaysReadableTitle() { @@ -31,6 +34,27 @@ final class SessionIndexViewTests: XCTestCase { XCTAssertEqual(entry.displayTitle, "/plugin install codex@openai-codex") } + func testClaudeSlashCommandMarkupIncludesDistinctMessage() { + let entry = makeEntry( + title: """ + /plugin + install + codex@openai-codex + """ + ) + + XCTAssertEqual(entry.displayTitle, "/plugin install codex@openai-codex") + } + + func testClaudeMetaSlashCommandMarkupDoesNotBecomeTitle() { + XCTAssertNil( + SessionEntry.claudeDisplayTitle( + from: "/plugin", + isMeta: true + ) + ) + } + func testClaudeResumeCommandPinsConfigDirectoryFromFileURL() { let entry = makeEntry( sessionId: "claude-session-123", @@ -44,6 +68,21 @@ final class SessionIndexViewTests: XCTestCase { ) } + func testClaudeResumeCommandUsesNearestProjectsDirectory() { + let entry = makeEntry( + sessionId: "claude-session-123", + title: "resume me", + fileURL: URL( + fileURLWithPath: "/tmp/projects/claude config/projects/-tmp/claude-session-123.jsonl" + ) + ) + + XCTAssertEqual( + entry.resumeCommand, + "env CLAUDE_CONFIG_DIR='/tmp/projects/claude config' claude --resume claude-session-123" + ) + } + func testCurrentDirectorySetterDoesNotPublishEqualValue() { let store = SessionIndexStore() var emittedValues: [String?] = []