diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index d2d6309817c..fc22801b003 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -3385,7 +3385,9 @@ struct TextBoxInputView: NSViewRepresentable { !textView.hasPendingAttachmentUploadPlaceholder() { textView.invalidatePendingAttachmentUploads() } - textView.refreshMentionCompletions() + if !textView.isHandlingDidChangeText { + textView.refreshMentionCompletions() + } recalculateHeight(textView) } @@ -3397,7 +3399,9 @@ struct TextBoxInputView: NSViewRepresentable { textView.window?.firstResponder === textView ? 0.45 : 0.24 ).cgColor textView.refreshInlineAttachmentFocus() - textView.refreshMentionCompletions() + if !textView.isHandlingDidChangeText { + textView.refreshMentionCompletions() + } } func noteMarkedTextStateChanged(_ hasMarkedText: Bool, from textView: TextBoxInputTextView? = nil) { @@ -3498,6 +3502,8 @@ struct TextBoxInputView: NSViewRepresentable { } final class TextBoxInputTextView: NSTextView { + fileprivate private(set) var isHandlingDidChangeText = false + var terminalTitle = "" var completionRootDirectory: String? { didSet { @@ -3541,6 +3547,8 @@ final class TextBoxInputTextView: NSTextView { private var mentionCompletionWindowObserverTokens: [NSObjectProtocol] = [] private weak var mentionCompletionObservedWindow: NSWindow? private var mentionCompletionRepositionIsScheduled = false + private var activeInsertTextDepth = 0 + private var didChangeTextDuringActiveInsertText = false private var pendingUndoableAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var pendingAutomaticAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var suppressAutomaticAttachmentFileCleanup = false @@ -3637,8 +3645,22 @@ final class TextBoxInputTextView: NSTextView { override func insertText(_ insertString: Any, replacementRange: NSRange) { queueAutomaticAttachmentFileCleanup(in: replacementRange) + let isOuterInsertText = activeInsertTextDepth == 0 + if isOuterInsertText { + didChangeTextDuringActiveInsertText = false + } + activeInsertTextDepth += 1 super.insertText(insertString, replacementRange: replacementRange) - flushAutomaticAttachmentFileCleanup() + activeInsertTextDepth = max(0, activeInsertTextDepth - 1) + let didChangeTextWasHandled = didChangeTextDuringActiveInsertText + if isOuterInsertText { + didChangeTextDuringActiveInsertText = false + } + if didChangeTextWasHandled { + flushAutomaticAttachmentFileCleanup() + } else { + didChangeText() + } onMarkedTextStateChanged(hasMarkedText()) } @@ -3653,8 +3675,14 @@ final class TextBoxInputTextView: NSTextView { } override func didChangeText() { + if activeInsertTextDepth > 0 { + didChangeTextDuringActiveInsertText = true + } + isHandlingDidChangeText = true + defer { isHandlingDidChangeText = false } super.didChangeText() flushAutomaticAttachmentFileCleanup() + refreshMentionCompletions() } override func copy(_ sender: Any?) { @@ -4128,13 +4156,21 @@ final class TextBoxInputTextView: NSTextView { func debugInteractionState() -> [String: Any] { let selection = selectedRange() + let mentionQuery = mentionCompletionController.activeQuery return [ "selected_location": selection.location, "selected_length": selection.length, "focused_attachment_index": focusedAttachmentCharacterIndex ?? -1, "preview_shown": isAttachmentPreviewShown, "attachment_count": inlineAttachments().count, - "plain_text": plainText() + "plain_text": plainText(), + "mention_active": mentionCompletionController.isActive, + "mention_query": mentionQuery?.query ?? "", + "mention_trigger": mentionQuery.map { String($0.trigger) } ?? "", + "mention_loading": mentionCompletionController.isLoadingSuggestions, + "mention_should_show": mentionCompletionController.debugShouldShowPopover, + "mention_current": mentionCompletionController.debugHasCurrentSuggestions, + "mention_titles": mentionCompletionController.debugSuggestionTitles ] } @@ -4970,6 +5006,10 @@ final class TextBoxInputTextView: NSTextView { mentionCompletionController.debugSuggestionCount } + func debugMentionSuggestionTitles() -> [String] { + mentionCompletionController.debugSuggestionTitles + } + func debugMentionSuggestionsAreCurrent() -> Bool { mentionCompletionController.debugHasCurrentSuggestions } diff --git a/Sources/TextBoxMentionCandidateIndex.swift b/Sources/TextBoxMentionCandidateIndex.swift index d0841fa499f..457e5fcc57d 100644 --- a/Sources/TextBoxMentionCandidateIndex.swift +++ b/Sources/TextBoxMentionCandidateIndex.swift @@ -17,7 +17,6 @@ struct TextBoxMentionCandidateIndex: Sendable { title: candidate.title, searchableTexts: [ candidate.title, - candidate.subtitle, candidate.searchKey ] ) @@ -49,12 +48,20 @@ struct TextBoxMentionCandidateIndex: Sendable { return Array(emptyQueryCandidates.prefix(limit)) } - if let nucleoIndex, - let nucleoResults = nucleoIndex.search( - query: query, - resultLimit: Self.nucleoProbeLimit(corpusCount: corpus.count, requestedLimit: limit), - shouldCancel: shouldCancel - ) { + if let nucleoIndex { + let probeLimit = Self.nucleoProbeLimit(corpusCount: corpus.count, requestedLimit: limit) + guard let nucleoResults = nucleoIndex.search( + query: query, + resultLimit: probeLimit, + shouldCancel: shouldCancel + ) else { + return Self.swiftRankedCandidates( + entries: corpus, + query: query, + limit: limit, + shouldCancel: shouldCancel + ) + } if shouldCancel() { return [] } let probedCorpus = nucleoResults.compactMap { result in corpusByTargetPath[result.payload.targetPath] @@ -65,10 +72,18 @@ struct TextBoxMentionCandidateIndex: Sendable { limit: limit, shouldCancel: shouldCancel ) - return Self.mergedRankedCandidates( - swiftMatches, - nucleoMatches: nucleoResults.map(\.payload), - limit: limit + let mayHaveUnprobedNucleoResults = probeLimit < corpus.count && + nucleoResults.count >= probeLimit + guard swiftMatches.count < limit, + mayHaveUnprobedNucleoResults else { + return swiftMatches + } + if shouldCancel() { return [] } + return Self.swiftRankedCandidates( + entries: corpus, + query: query, + limit: limit, + shouldCancel: shouldCancel ) } @@ -91,8 +106,26 @@ struct TextBoxMentionCandidateIndex: Sendable { limit: Int, shouldCancel: @escaping () -> Bool ) -> [TextBoxMentionCandidate] { - CommandPaletteSearchEngine.search( - entries: entries, + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let filteredEntries: [CommandPaletteSearchCorpusEntry] + if preparedQuery.isEmpty { + filteredEntries = entries + } else { + var matches: [CommandPaletteSearchCorpusEntry] = [] + matches.reserveCapacity(min(entries.count, limit)) + for entry in entries { + if shouldCancel() { return [] } + if mentionCandidate(entry, matches: preparedQuery) { + matches.append(entry) + } + } + if shouldCancel() { return [] } + filteredEntries = matches + } + guard !filteredEntries.isEmpty else { return [] } + + return CommandPaletteSearchEngine.search( + entries: filteredEntries, query: query, resultLimit: limit, historyBoost: { _, _ in 0 }, @@ -101,29 +134,23 @@ struct TextBoxMentionCandidateIndex: Sendable { .map(\.payload) } - private static func mergedRankedCandidates( - _ swiftMatches: [TextBoxMentionCandidate], - nucleoMatches: [TextBoxMentionCandidate], - limit: Int - ) -> [TextBoxMentionCandidate] { - var merged: [TextBoxMentionCandidate] = [] - var seenTargetPaths = Set() - merged.reserveCapacity(min(limit, swiftMatches.count + nucleoMatches.count)) - - func append(_ candidate: TextBoxMentionCandidate) { - guard merged.count < limit, - seenTargetPaths.insert(candidate.targetPath).inserted else { - return + private static func mentionCandidate( + _ entry: CommandPaletteSearchCorpusEntry, + matches preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery + ) -> Bool { + guard !preparedQuery.isEmpty else { return true } + for token in preparedQuery.tokens { + var tokenMatchesCandidate = false + for candidate in entry.preparedSearchableTexts where CommandPaletteFuzzyMatcher + .tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) { + tokenMatchesCandidate = true + break + } + if !tokenMatchesCandidate { + return false } - merged.append(candidate) - } - - for candidate in swiftMatches { - append(candidate) - } - for candidate in nucleoMatches { - append(candidate) } - return merged + return true } + } diff --git a/Sources/TextBoxMentionCompletionController.swift b/Sources/TextBoxMentionCompletionController.swift index 333e86d14eb..fce3b0be6f6 100644 --- a/Sources/TextBoxMentionCompletionController.swift +++ b/Sources/TextBoxMentionCompletionController.swift @@ -4,6 +4,8 @@ import Observation @MainActor @Observable final class TextBoxMentionCompletionController { + private static let maxVisibleStaleSuggestionsToFilter = 500 + private(set) var suggestions: [TextBoxMentionSuggestion] = [] private(set) var selectionIndex: Int = 0 private(set) var isLoadingSuggestions = false @@ -62,14 +64,25 @@ final class TextBoxMentionCompletionController { activeRootDirectory = rootDirectory selectionIndex = 0 isLoadingSuggestions = true - // Editing within the same trigger keeps the current rows on screen until - // the async lookup returns, avoiding a per-keystroke popover flicker. - // Switching triggers is a different completion kind, so drop stale rows - // immediately rather than showing them under the wrong trigger. - if previousActiveQuery?.trigger != query.trigger || previousRootDirectory != rootDirectory { + // Moving between a bare trigger and typed query changes the expected + // result shape. Show the loading row until that exact query finishes. + let previousQueryWasEmpty = previousActiveQuery?.query + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true + let queryIsEmpty = query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let queryChangedToNonEmpty = previousQueryWasEmpty && + !queryIsEmpty + let queryChangedToEmpty = !previousQueryWasEmpty && queryIsEmpty + if previousActiveQuery?.trigger != query.trigger || + previousRootDirectory != rootDirectory || + queryChangedToNonEmpty || + queryChangedToEmpty { suggestions = [] suggestionsQuery = nil suggestionsRootDirectory = nil + } else if previousActiveQuery?.query != query.query, + !queryIsEmpty { + filterVisibleStaleSuggestions(matching: query.query) } lookupTask?.cancel() lookupGeneration &+= 1 @@ -120,6 +133,46 @@ final class TextBoxMentionCompletionController { onStateChanged?() } + private func filterVisibleStaleSuggestions(matching query: String) { + let normalizedQuery = Self.normalizedMentionSearchText(query) + guard !normalizedQuery.isEmpty, !suggestions.isEmpty else { return } + if suggestions.count > Self.maxVisibleStaleSuggestionsToFilter { + // The index store caps visible rows at this size; oversized injected + // stale state is safer to clear than filter on the main actor. + suggestions = [] + } else { + suggestions = suggestions.filter { suggestion in + Self.title(suggestion.title, matchesNormalizedQuery: normalizedQuery) + } + } + suggestionsQuery = nil + suggestionsRootDirectory = nil + selectionIndex = suggestions.isEmpty ? 0 : min(selectionIndex, suggestions.count - 1) + } + + private static func title(_ title: String, matchesNormalizedQuery normalizedQuery: String) -> Bool { + let normalizedTitle = normalizedMentionSearchText(title) + .trimmingCharacters(in: CharacterSet(charactersIn: "/$@")) + guard !normalizedQuery.isEmpty else { return true } + guard !normalizedTitle.isEmpty else { return false } + if normalizedTitle.contains(normalizedQuery) { return true } + + var candidateIndex = normalizedTitle.startIndex + for queryCharacter in normalizedQuery { + guard let matchIndex = normalizedTitle[candidateIndex...].firstIndex(of: queryCharacter) else { + return false + } + candidateIndex = normalizedTitle.index(after: matchIndex) + } + return true + } + + private static func normalizedMentionSearchText(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + deinit { lookupTask?.cancel() } @@ -155,5 +208,9 @@ final class TextBoxMentionCompletionController { var debugShouldShowPopover: Bool { shouldShowPopover } + + var debugSuggestionTitles: [String] { + suggestions.map(\.title) + } #endif } diff --git a/Sources/TextBoxMentionIndexStore.swift b/Sources/TextBoxMentionIndexStore.swift index e14f384fef7..53d8d9be7f6 100644 --- a/Sources/TextBoxMentionIndexStore.swift +++ b/Sources/TextBoxMentionIndexStore.swift @@ -284,7 +284,7 @@ actor TextBoxMentionIndexStore { subtitle: Self.displayPath(path), targetPath: path, systemImageName: "sparkle.magnifyingglass", - searchKey: "\(skillName) \(path)".lowercased(), + searchKey: Self.skillSearchKey(skillName: skillName, skillURL: skillURL, rootURL: root), priority: rootIndex )) if candidates.count >= Self.maxIndexedSkills { @@ -897,6 +897,15 @@ actor TextBoxMentionIndexStore { return skillURL.deletingLastPathComponent().lastPathComponent } + private static func skillSearchKey(skillName: String, skillURL: URL, rootURL: URL) -> String { + let skillDirectory = skillURL.deletingLastPathComponent().standardizedFileURL + let relativeSkillPath = relativePath( + for: skillDirectory.path, + rootPath: rootURL.standardizedFileURL.path + ) + return "\(skillName) \(relativeSkillPath)".lowercased() + } + private static func normalizedDirectory(_ path: String?) -> String? { guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 0e45e98c3f5..b26d2d9a631 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -928,46 +928,402 @@ struct TextBoxMentionCompletionTests { } @Test - func testTextBoxMentionRefreshKeepsRowsOnSameTriggerEditButClearsOnTriggerChange() { + func testTextBoxMentionSkillSuggestionsPreferExactNameOverPathOnlyFuzzyMatches() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-skill-fuzzy-filter-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let skillsDirectory = root.appendingPathComponent("skills", isDirectory: true) + let skillNames = [ + "agent-browser", + "agent-cli-integration", + "algorithmic-complexity-audit", + "auto-issue", + "cleanup-dev-builds", + "close-issues", + "pi-agent-rust", + "xcodebuildmcp-cli", + "iterate-pr" + ] + (0..<40).map { String(format: "zzz-distractor-%02d", $0) } + for skillName in skillNames { + let skillDirectory = skillsDirectory.appendingPathComponent(skillName, isDirectory: true) + try fileManager.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + try "name: \(skillName)\n".write( + to: skillDirectory.appendingPathComponent("SKILL.md"), + atomically: true, + encoding: .utf8 + ) + } + + for trigger in ["/", "$"] as [Character] { + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 11), + query: "iterate-pr", + trigger: trigger + ), + rootDirectory: root.path + ) + + #expect(suggestions.first?.title == "\(trigger)iterate-pr") + #expect(!suggestions.contains { $0.title == "\(trigger)pi-agent-rust" }) + #expect(!suggestions.contains { $0.title == "\(trigger)agent-browser" }) + } + } + + @Test + func testTextBoxMentionSkillSuggestionsFilterWeakPartialFuzzyMatches() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-skill-partial-fuzzy-filter-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let skillsDirectory = root.appendingPathComponent("skills", isDirectory: true) + for skillName in [ + "agent-browser", + "agent-cli-integration", + "pi-agent-rust", + "iterate-pr" + ] { + let skillDirectory = skillsDirectory.appendingPathComponent(skillName, isDirectory: true) + try fileManager.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + try "name: \(skillName)\n".write( + to: skillDirectory.appendingPathComponent("SKILL.md"), + atomically: true, + encoding: .utf8 + ) + } + + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 8), + query: "iterate", + trigger: "/" + ), + rootDirectory: root.path + ) + + #expect(suggestions.first?.title == "/iterate-pr") + #expect(!suggestions.contains { $0.title == "/agent-browser" }) + #expect(!suggestions.contains { $0.title == "/pi-agent-rust" }) + } + + @Test + func testTextBoxMentionCandidateIndexDoesNotReturnUnvalidatedNucleoRows() { + let skillNames = [ + "agent-browser", + "agent-cli-integration", + "algorithmic-complexity-audit", + "auto-issue", + "cleanup-dev-builds", + "close-issues", + "pi-agent-rust", + "xcodebuildmcp-cli" + ] + (0..<40).map { String(format: "zzz-distractor-%02d", $0) } + let candidates = skillNames.map { skillName in + TextBoxMentionCandidate( + title: "/\(skillName)", + subtitle: "/tmp/skills/\(skillName)/SKILL.md", + targetPath: "/tmp/skills/\(skillName)/SKILL.md", + systemImageName: "sparkle.magnifyingglass", + searchKey: skillName, + priority: 0 + ) + } + + let matches = TextBoxMentionCandidateIndex(candidates: candidates).rankedCandidates( + matching: "iterate-pr", + limit: 500 + ) + + #expect(matches.isEmpty) + } + + @Test + func testTextBoxMentionCandidateIndexFiltersWeakPartialFuzzyRows() { + let candidates = [ + "agent-browser", + "agent-cli-integration", + "pi-agent-rust", + "iterate-pr" + ].map { skillName in + TextBoxMentionCandidate( + title: "/\(skillName)", + subtitle: "/tmp/skills/\(skillName)/SKILL.md", + targetPath: "/tmp/skills/\(skillName)/SKILL.md", + systemImageName: "sparkle.magnifyingglass", + searchKey: skillName, + priority: 0 + ) + } + + let matches = TextBoxMentionCandidateIndex(candidates: candidates).rankedCandidates( + matching: "iterate", + limit: 500 + ) + + #expect(matches.map(\.title) == ["/iterate-pr"]) + } + + @Test + func testTextBoxMentionCandidateIndexStopsPrefilterWhenCancelled() { + let candidates = [ + "agent-browser", + "agent-cli-integration", + "pi-agent-rust", + "iterate-pr" + ].map { skillName in + TextBoxMentionCandidate( + title: "/\(skillName)", + subtitle: "/tmp/skills/\(skillName)/SKILL.md", + targetPath: "/tmp/skills/\(skillName)/SKILL.md", + systemImageName: "sparkle.magnifyingglass", + searchKey: skillName, + priority: 0 + ) + } + var cancellationChecks = 0 + + let matches = TextBoxMentionCandidateIndex(candidates: candidates).rankedCandidates( + matching: "iterate", + limit: 500 + ) { + cancellationChecks += 1 + return cancellationChecks > 1 + } + + #expect(matches.isEmpty) + #expect(cancellationChecks > 1) + } + + @Test + func testTextBoxMentionRefreshClearsRowsWhenSameTriggerQueryBecomesNonEmpty() { let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) - textView.string = "@a" - textView.setSelectedRange(NSRange(location: 2, length: 0)) + textView.string = "$" + textView.setSelectedRange(NSRange(location: 1, length: 0)) let staleSuggestion = TextBoxMentionSuggestion( - id: "alpha", - title: "@alpha.txt", - subtitle: "alpha.txt", - insertionText: "[@alpha.txt](/tmp/alpha.txt)", - systemImageName: "doc" + id: "$:/tmp/agent-browser/SKILL.md", + title: "$agent-browser", + subtitle: "/tmp/agent-browser/SKILL.md", + insertionText: "$agent-browser", + systemImageName: "sparkle.magnifyingglass" ) textView.debugSetMentionCompletionState( - query: TextBoxMentionQuery(kind: .file, range: NSRange(location: 0, length: 2), query: "a"), + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 1), + query: "", + trigger: "$" + ), suggestions: [staleSuggestion] ) #expect(textView.debugMentionSuggestionCount() == 1) - // Editing within the same trigger keeps the previous rows visible until - // the async lookup returns, avoiding a per-keystroke popover flicker. - textView.string = "@z" - textView.setSelectedRange(NSRange(location: 2, length: 0)) + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) textView.refreshMentionCompletions() - #expect(textView.debugMentionSuggestionCount() == 1) - #expect(!(textView.debugMentionSuggestionsAreCurrent())) + #expect(textView.debugMentionSuggestionCount() == 0) + #expect(textView.debugMentionCompletionsShouldShowPopover()) #expect(!(textView.debugAcceptMentionCompletion())) #expect(!(textView.debugAcceptMentionCompletion(suggestion: staleSuggestion))) - #expect(textView.string == "@z") - var submitCount = 0 - textView.onSubmit = { submitCount += 1 } - textView.doCommand(by: #selector(NSResponder.insertNewline(_:))) - #expect(submitCount == 1) - #expect(textView.string == "@z") - - // Switching the trigger is a different completion kind, so stale rows are - // dropped immediately rather than shown under the wrong trigger. - textView.string = "/z" - textView.setSelectedRange(NSRange(location: 2, length: 0)) + } + + @Test + func testTextBoxMentionDidChangeTextRefreshesRowsWithoutDelegateNotification() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "$" + textView.setSelectedRange(NSRange(location: 1, length: 0)) + let staleSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/agent-browser/SKILL.md", + title: "$agent-browser", + subtitle: "/tmp/agent-browser/SKILL.md", + insertionText: "$agent-browser", + systemImageName: "sparkle.magnifyingglass" + ) + + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 1), + query: "", + trigger: "$" + ), + suggestions: [staleSuggestion] + ) + + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) + textView.didChangeText() + + #expect(textView.debugMentionSuggestionCount() == 0) + #expect(textView.debugMentionCompletionsShouldShowPopover()) + #expect(!textView.debugAcceptMentionCompletion(suggestion: staleSuggestion)) + } + + @Test + func testTextBoxMentionRefreshKeepsRowsWhenSameTriggerQueryStaysNonEmpty() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "$it" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + let currentSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/iterate-pr/SKILL.md", + title: "$iterate-pr", + subtitle: "/tmp/iterate-pr/SKILL.md", + insertionText: "$iterate-pr", + systemImageName: "sparkle.magnifyingglass" + ) + + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 3), + query: "it", + trigger: "$" + ), + suggestions: [currentSuggestion] + ) + #expect(textView.debugMentionSuggestionCount() == 1) + + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) + textView.refreshMentionCompletions() + #expect(textView.debugMentionSuggestionCount() == 1) + #expect(!textView.debugMentionSuggestionsAreCurrent()) + #expect(!textView.debugAcceptMentionCompletion()) + } + + @Test + func testTextBoxMentionRefreshFiltersStaleRowsWhenSameTriggerQueryNarrows() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "$it" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + let staleSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/agent-browser/SKILL.md", + title: "$agent-browser", + subtitle: "/tmp/agent-browser/SKILL.md", + insertionText: "$agent-browser", + systemImageName: "sparkle.magnifyingglass" + ) + let currentSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/iterate-pr/SKILL.md", + title: "$iterate-pr", + subtitle: "/tmp/iterate-pr/SKILL.md", + insertionText: "$iterate-pr", + systemImageName: "sparkle.magnifyingglass" + ) + + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 3), + query: "it", + trigger: "$" + ), + suggestions: [staleSuggestion, currentSuggestion] + ) + + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) + textView.refreshMentionCompletions() + + #expect(textView.debugMentionSuggestionTitles() == ["$iterate-pr"]) + #expect(!textView.debugMentionSuggestionsAreCurrent()) + #expect(!textView.debugAcceptMentionCompletion(suggestion: staleSuggestion)) + } + + @Test + func testTextBoxMentionFilteredRowsStayNonCurrentWhenQueryReturnsToPreviousValue() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "$it" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + let staleSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/agent-browser/SKILL.md", + title: "$agent-browser", + subtitle: "/tmp/agent-browser/SKILL.md", + insertionText: "$agent-browser", + systemImageName: "sparkle.magnifyingglass" + ) + let currentSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/iterate-pr/SKILL.md", + title: "$iterate-pr", + subtitle: "/tmp/iterate-pr/SKILL.md", + insertionText: "$iterate-pr", + systemImageName: "sparkle.magnifyingglass" + ) + + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 3), + query: "it", + trigger: "$" + ), + suggestions: [staleSuggestion, currentSuggestion] + ) + + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) + textView.refreshMentionCompletions() + #expect(textView.debugMentionSuggestionTitles() == ["$iterate-pr"]) + #expect(!textView.debugMentionSuggestionsAreCurrent()) + + textView.string = "$it" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + textView.refreshMentionCompletions() + #expect(textView.debugMentionSuggestionTitles() == ["$iterate-pr"]) + #expect(!textView.debugMentionSuggestionsAreCurrent()) + #expect(!textView.debugAcceptMentionCompletion()) + } + + @Test + func testTextBoxMentionRefreshClearsFilteredRowsWhenQueryReturnsToBareTrigger() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "$it" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + let staleSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/agent-browser/SKILL.md", + title: "$agent-browser", + subtitle: "/tmp/agent-browser/SKILL.md", + insertionText: "$agent-browser", + systemImageName: "sparkle.magnifyingglass" + ) + let currentSuggestion = TextBoxMentionSuggestion( + id: "$:/tmp/iterate-pr/SKILL.md", + title: "$iterate-pr", + subtitle: "/tmp/iterate-pr/SKILL.md", + insertionText: "$iterate-pr", + systemImageName: "sparkle.magnifyingglass" + ) + + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: NSRange(location: 0, length: 3), + query: "it", + trigger: "$" + ), + suggestions: [staleSuggestion, currentSuggestion] + ) + + textView.string = "$iterate-pr" + textView.setSelectedRange(NSRange(location: 11, length: 0)) + textView.refreshMentionCompletions() + #expect(textView.debugMentionSuggestionTitles() == ["$iterate-pr"]) + + textView.string = "$" + textView.setSelectedRange(NSRange(location: 1, length: 0)) textView.refreshMentionCompletions() #expect(textView.debugMentionSuggestionCount() == 0) + #expect(textView.debugMentionCompletionsShouldShowPopover()) + #expect(!textView.debugAcceptMentionCompletion()) } @Test diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index af63a3d3354..d098b66b843 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -4,17 +4,35 @@ import Darwin final class AutomationSocketUITests: XCTestCase { private var socketPath = "" + private var diagnosticsPath = "" private let defaultsDomain = "com.cmuxterm.app.debug" private let modeKey = "socketControlMode" private let legacyKey = "socketControlEnabled" - private let launchTag = "ui-tests-automation-socket" + private var launchTag = "" + private var temporaryRoots: [URL] = [] override func setUp() { super.setUp() continueAfterFailure = false socketPath = "/tmp/cmux-debug-\(UUID().uuidString).sock" + diagnosticsPath = "/tmp/cmux-ui-test-automation-socket-\(UUID().uuidString).json" + launchTag = "ui-tests-automation-\(UUID().uuidString.prefix(8))" + temporaryRoots = [] resetSocketDefaults() removeSocketFile() + try? FileManager.default.removeItem(atPath: diagnosticsPath) + try? FileManager.default.removeItem(atPath: taggedSocketPath()) + } + + override func tearDown() { + removeSocketFile() + try? FileManager.default.removeItem(atPath: diagnosticsPath) + try? FileManager.default.removeItem(atPath: taggedSocketPath()) + for root in temporaryRoots { + try? FileManager.default.removeItem(at: root) + } + temporaryRoots = [] + super.tearDown() } func testSocketToggleDisablesAndEnables() { @@ -70,17 +88,114 @@ final class AutomationSocketUITests: XCTestCase { app.terminate() } + func testTextBoxSkillMentionFiltersWhenTypingAfterBareDollarTrigger() throws { + let skillRoot = try makeSkillFixtureRoot( + skillNames: [ + "agent-browser", + "agent-cli-integration", + "iterate-pr", + ] + ) + let app = XCUIApplication() + configureTextBoxMentionLaunchEnvironment(app) + defer { app.terminate() } + app.launch() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for textbox mention test. state=\(app.state.rawValue)" + ) + XCTAssertTrue( + waitForSocketPong(timeout: 12.0), + "Expected socket ping at \(socketPath). diagnostics=\(loadDiagnostics())" + ) + + let workspace = try XCTUnwrap( + socketResult( + method: "workspace.create", + params: [ + "title": "Textbox mention XCUITest", + "working_directory": skillRoot.path, + "focus": true, + ] + ), + "Expected workspace.create to succeed" + ) + let surfaceID = try XCTUnwrap(workspace["surface_id"] as? String, "Expected created surface id") + + _ = try XCTUnwrap( + waitForTextBoxFixture(surfaceID: surfaceID, beforeText: "$", timeout: 8.0), + "Expected text box fixture to mount with a bare $ trigger" + ) + _ = try XCTUnwrap( + socketResult( + method: "debug.textbox.interact", + params: ["surface_id": surfaceID, "action": "focus"] + ), + "Expected text box focus to succeed" + ) + + let bareState = try XCTUnwrap( + waitForMentionState(surfaceID: surfaceID, timeout: 8.0) { state in + let titles = state["mention_titles"] as? [String] ?? [] + return state["mention_trigger"] as? String == "$" && + state["mention_query"] as? String == "" && + titles.contains("$agent-browser") + }, + "Expected bare $ suggestions to include $agent-browser" + ) + XCTAssertEqual(bareState["plain_text"] as? String, "$") + + app.typeText("iterate") + + let typedState = try XCTUnwrap( + waitForMentionState(surfaceID: surfaceID, timeout: 8.0) { state in + let titles = state["mention_titles"] as? [String] ?? [] + return state["plain_text"] as? String == "$iterate" && + state["mention_trigger"] as? String == "$" && + state["mention_query"] as? String == "iterate" && + state["mention_current"] as? Bool == true && + titles.contains("$iterate-pr") && + !titles.contains("$agent-browser") + }, + "Expected typing iterate after bare $ to filter stale $agent-browser and show $iterate-pr" + ) + + let typedTitles = typedState["mention_titles"] as? [String] ?? [] + XCTAssertEqual(typedTitles.first, "$iterate-pr") + } + private func configuredApp(mode: String) -> XCUIApplication { let app = XCUIApplication() app.launchArguments += ["-\(modeKey)", mode] app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath // Debug launches require a tag outside reload.sh; provide one in UITests so CI // does not fail with "Application ... does not have a process ID". app.launchEnvironment["CMUX_TAG"] = launchTag return app } + private func configureTextBoxMentionLaunchEnvironment(_ app: XCUIApplication) { + app.launchArguments += [ + "-\(modeKey)", "allowAll", + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + ] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath + app.launchEnvironment["CMUX_TAG"] = launchTag + if let path = ProcessInfo.processInfo.environment["PATH"], !path.isEmpty { + app.launchEnvironment["PATH"] = path + } + } + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { if app.wait(for: .runningForeground, timeout: timeout) { return true @@ -93,29 +208,177 @@ final class AutomationSocketUITests: XCTestCase { return false } + private func waitForSocketPong(timeout: TimeInterval) -> Bool { + var resolvedPath: String? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let originalPath = self.socketPath + for candidate in self.socketCandidates() { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + self.socketPath = candidate + if self.socketCommand("ping") == "PONG" { + resolvedPath = candidate + return true + } + self.socketPath = originalPath + } + return false + }, + object: NSObject() + ) + let completed = XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + if let resolvedPath { + socketPath = resolvedPath + } + return completed + } + + private func socketCandidates() -> [String] { + var candidates = [socketPath, taggedSocketPath()] + if let expectedPath = loadDiagnostics()["socketExpectedPath"], !expectedPath.isEmpty { + candidates.append(expectedPath) + } + var seen = Set() + candidates.removeAll { !seen.insert($0).inserted } + return candidates + } + + private func taggedSocketPath() -> String { + let slug = launchTag + .lowercased() + .replacingOccurrences(of: ".", with: "-") + .replacingOccurrences(of: "_", with: "-") + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + return "/tmp/cmux-debug-\(slug).sock" + } + + private func loadDiagnostics() -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + var diagnostics: [String: String] = [:] + for (key, value) in object { + diagnostics[key] = String(describing: value) + } + return diagnostics + } + private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - FileManager.default.fileExists(atPath: self.socketPath) == exists + if exists { + return self.socketCandidates().contains { FileManager.default.fileExists(atPath: $0) } + } + return !self.socketCandidates().contains { FileManager.default.fileExists(atPath: $0) } }, object: NSObject() ) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } - private func waitForSocketPong(timeout: TimeInterval) -> Bool { + private func socketCommand(_ command: String) -> String? { + ControlSocketClient(path: socketPath, responseTimeout: 1.0).sendLine(command) + } + + private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? { + let request: [String: Any] = [ + "id": UUID().uuidString, + "method": method, + "params": params, + ] + return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request) + } + + private func socketResult(method: String, params: [String: Any]) -> [String: Any]? { + guard let envelope = socketJSON(method: method, params: params), + envelope["ok"] as? Bool == true else { + return nil + } + return envelope["result"] as? [String: Any] + } + + private func waitForTextBoxFixture( + surfaceID: String, + beforeText: String, + timeout: TimeInterval + ) -> [String: Any]? { + waitForJSON(timeout: timeout) { + guard let result = self.socketResult( + method: "debug.textbox.inline_fixture", + params: [ + "surface_id": surfaceID, + "before_text": beforeText, + "after_text": "", + ] + ) else { + return nil + } + guard result["text_view_has_window"] as? Bool == true, + result["text_view_text"] as? String == beforeText else { + return nil + } + return result + } + } + + private func waitForMentionState( + surfaceID: String, + timeout: TimeInterval, + predicate: @escaping ([String: Any]) -> Bool + ) -> [String: Any]? { + waitForJSON(timeout: timeout) { + guard let result = self.socketResult( + method: "debug.textbox.interact", + params: ["surface_id": surfaceID, "action": "focus"] + ), + let state = result["state"] as? [String: Any] else { + return nil + } + return predicate(state) ? state : nil + } + } + + private func waitForJSON( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + producer: () -> [String: Any]? + ) -> [String: Any]? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true + if let value = producer() { + return value } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) } - return socketCommand("ping") == "PONG" + return producer() } - private func socketCommand(_ command: String) -> String? { - ControlSocketClient(path: socketPath, responseTimeout: 1.0).sendLine(command) + private func makeSkillFixtureRoot(skillNames: [String]) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-textbox-skills-\(UUID().uuidString)", isDirectory: true) + let skills = root.appendingPathComponent("skills", isDirectory: true) + try FileManager.default.createDirectory(at: skills, withIntermediateDirectories: true) + for skillName in skillNames { + let skillDirectory = skills.appendingPathComponent(skillName, isDirectory: true) + try FileManager.default.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + let contents = """ + --- + name: \(skillName) + --- + + Test skill fixture for \(skillName). + """ + try contents.write( + to: skillDirectory.appendingPathComponent("SKILL.md", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + } + temporaryRoots.append(root) + return root } private func resolveSocketPath(timeout: TimeInterval, allowTmpFallback: Bool = true) -> String? { @@ -190,6 +453,17 @@ final class AutomationSocketUITests: XCTestCase { self.responseTimeout = responseTimeout } + func sendJSON(_ object: [String: Any]) -> [String: Any]? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object), + let line = String(data: data, encoding: .utf8), + let response = sendLine(line), + let responseData = response.data(using: .utf8) else { + return nil + } + return (try? JSONSerialization.jsonObject(with: responseData)) as? [String: Any] + } + func sendLine(_ line: String) -> String? { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } @@ -235,10 +509,22 @@ final class AutomationSocketUITests: XCTestCase { guard wrote else { return nil } var buffer = [UInt8](repeating: 0, count: 4096) - let count = Darwin.read(fd, &buffer, buffer.count) - guard count > 0 else { return nil } - return String(bytes: buffer[0.. 0 else { break } + if let chunk = String(bytes: buffer[0..