From f3609b91f84b6b2f91631de41541dcf05e18ba1e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 03:28:34 -0700 Subject: [PATCH 1/7] Anchor textbox completions to cursor --- Sources/App/ShortcutRoutingSupport.swift | 22 + Sources/AppDelegate.swift | 16 + Sources/TextBoxInput.swift | 660 +++++++++++++++--- .../AppDelegateShortcutRoutingTests.swift | 276 +++++++- 4 files changed, 862 insertions(+), 112 deletions(-) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 5e59b602c7..ab4f85907f 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -191,6 +191,28 @@ func shouldDispatchTextBoxInputArrowViaFirstResponderKeyDown( } } +/// Ctrl-N / Ctrl-P navigate the mention-completion popover (and emacs-style line +/// movement) inside the terminal textbox. Like plain arrows, the window's +/// `performKeyEquivalent` claims these before they reach the textbox `keyDown`, so +/// they must be routed to the first responder explicitly. Scoped to the textbox so +/// terminal/browser Ctrl-N/Ctrl-P are unaffected. +func shouldDispatchTextBoxInputControlNavViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsTextBoxInput: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsTextBoxInput else { return false } + guard !firstResponderHasMarkedText else { return false } + + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags == [.control] else { return false } + // kVK_ANSI_N == 45, kVK_ANSI_P == 35 + return keyCode == 45 || keyCode == 35 +} + func shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: NSEvent.ModifierFlags, chars: String, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d0bf021a39..98129e05c4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15790,6 +15790,7 @@ private var cmuxBrowserArrowForwardingDepth = 0 private var cmuxBrowserOmnibarMarkedTextForwardingDepth = 0 private var cmuxCommandPaletteArrowForwardingDepth = 0 private var cmuxTextBoxInputArrowForwardingDepth = 0 +private var cmuxTextBoxInputControlNavForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -16614,6 +16615,21 @@ private extension NSWindow { return true } + if shouldDispatchTextBoxInputControlNavViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsTextBoxInput: firstResponderIsTextBoxInput, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + if cmuxTextBoxInputControlNavForwardingDepth > 0 { + return false + } + cmuxTextBoxInputControlNavForwardingDepth += 1 + defer { cmuxTextBoxInputControlNavForwardingDepth = max(0, cmuxTextBoxInputControlNavForwardingDepth - 1) } + self.firstResponder?.keyDown(with: event) + return true + } + // Web forms rely on Return/Enter flowing through keyDown. If the original // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches // WebKit. Route Return/Enter directly to the current first responder and diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 7fa0c9aee2..61e3aaecc5 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -1540,9 +1540,6 @@ enum TextBoxMentionCompletionDetector { } let query = String(token.dropFirst()) - if kind == .skill, query.isEmpty { - return nil - } return TextBoxMentionQuery(kind: kind, range: tokenRange, query: query, trigger: trigger) } } @@ -1569,7 +1566,7 @@ private enum TextBoxMentionMarkdown { private struct TextBoxMentionCandidate: Sendable { let title: String let subtitle: String - let insertionText: String + let targetPath: String let systemImageName: String let searchKey: String let priority: Int @@ -1582,8 +1579,18 @@ private struct TextBoxMentionCandidate: Sendable { displayTitle = title } + let insertionText: String + if trigger == "$" { + // The $ trigger intentionally inserts the bare skill reference + // (e.g. "$skill-name") as a plain-text shorthand. The / and @ + // triggers insert a markdown link instead. + insertionText = displayTitle + } else { + insertionText = TextBoxMentionMarkdown.link(label: displayTitle, path: targetPath) + } + return TextBoxMentionSuggestion( - id: insertionText, + id: "\(trigger):\(targetPath)", title: displayTitle, subtitle: subtitle, insertionText: insertionText, @@ -1614,6 +1621,18 @@ private struct TextBoxMentionCandidateIndex: Sendable { func rankedCandidates(matching rawQuery: String, limit: Int) -> [TextBoxMentionCandidate] { let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + return corpus + .sorted { lhs, rhs in + if lhs.rank != rhs.rank { + return lhs.rank < rhs.rank + } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + .prefix(limit) + .map(\.payload) + } + if let nucleoResults = nucleoIndex?.search( query: query, resultLimit: limit, @@ -1640,10 +1659,10 @@ actor TextBoxMentionIndexStore { let createdAt: Date } - private static let fileIndexTTL: TimeInterval = 2 + private static let fileIndexTTL: TimeInterval = 30 private static let maxIndexedFiles = 6000 private static let maxIndexedSkills = 800 - private static let suggestionLimit = 8 + private static let suggestionLimit = 500 private static let skippedDirectoryNames: Set = [ ".build", ".git", @@ -1656,8 +1675,26 @@ actor TextBoxMentionIndexStore { "Pods", "vendor" ] + private static let skippedPackageDirectorySuffixes = [ + ".app", + ".appex", + ".bundle", + ".dSYM", + ".framework", + ".kext", + ".mdimporter", + ".plugin", + ".prefPane", + ".qlgenerator", + ".rtfd", + ".xcframework", + ".xcodeproj", + ".xcworkspace", + ".playground" + ] private var fileIndexesByRoot: [String: CachedIndex] = [:] + private var fileIndexRefreshTasks: [String: Task] = [:] private var skillIndexesByRootKey: [String: TextBoxMentionCandidateIndex] = [:] func suggestions( @@ -1667,7 +1704,7 @@ actor TextBoxMentionIndexStore { switch query.kind { case .file: guard let rootDirectory = Self.normalizedDirectory(rootDirectory) else { return [] } - return fileSuggestions(for: query, rootDirectory: rootDirectory) + return await fileSuggestions(for: query, rootDirectory: rootDirectory) case .skill: let index = skillIndex(rootDirectory: Self.normalizedDirectory(rootDirectory)) return index.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) @@ -1675,38 +1712,92 @@ actor TextBoxMentionIndexStore { } } + func warmIndexes(rootDirectory: String?) { + let normalizedRootDirectory = Self.normalizedDirectory(rootDirectory) + _ = skillIndex(rootDirectory: normalizedRootDirectory) + } + private func fileSuggestions( for query: TextBoxMentionQuery, rootDirectory: String - ) -> [TextBoxMentionSuggestion] { + ) async -> [TextBoxMentionSuggestion] { + if query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return Self.scanRootFiles(rootURL: URL(fileURLWithPath: rootDirectory, isDirectory: true)) + .prefix(Self.suggestionLimit) + .map { $0.suggestion(trigger: query.trigger) } + } + let now = Date() - let index = fileIndex(rootDirectory: rootDirectory, now: now) + let index = await fileIndex(rootDirectory: rootDirectory, now: now) var matches = index.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) if matches.isEmpty, !query.query.isEmpty { - let refreshed = refreshFileIndex(rootDirectory: rootDirectory, now: now) + let refreshed = await refreshFileIndex(rootDirectory: rootDirectory, now: now) matches = refreshed.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) } return matches .map { $0.suggestion(trigger: query.trigger) } } + private static func scanRootFiles(rootURL: URL) -> [TextBoxMentionCandidate] { + let fileManager = FileManager.default + let rootPath = rootURL.standardizedFileURL.path + guard let children = try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return children.compactMap { child -> TextBoxMentionCandidate? in + let standardizedURL = child.standardizedFileURL + guard (try? standardizedURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + return nil + } + let relativePath = relativePath(for: standardizedURL.path, rootPath: rootPath) + return TextBoxMentionCandidate( + title: "@\(relativePath)", + subtitle: displayPath(standardizedURL.path), + targetPath: standardizedURL.path, + systemImageName: "doc", + searchKey: "\(relativePath) \(standardizedURL.lastPathComponent)".lowercased(), + priority: 0 + ) + } + .sorted { + $0.title.localizedStandardCompare($1.title) == .orderedAscending + } + } + private func fileIndex( rootDirectory: String, now: Date - ) -> TextBoxMentionCandidateIndex { + ) async -> TextBoxMentionCandidateIndex { if let cached = fileIndexesByRoot[rootDirectory], now.timeIntervalSince(cached.createdAt) < Self.fileIndexTTL { return cached.index } - return refreshFileIndex(rootDirectory: rootDirectory, now: now) + return await refreshFileIndex(rootDirectory: rootDirectory, now: now) } private func refreshFileIndex( rootDirectory: String, now: Date - ) -> TextBoxMentionCandidateIndex { + ) async -> TextBoxMentionCandidateIndex { + // Coalesce concurrent refreshes: while one scan is in flight for a root, + // additional keystrokes await the same scan instead of each spawning a + // fresh (and expensive) `rg`/filesystem walk. The detached scan is not + // cancelled, so a join here is correct even if the caller's lookup task is. + if let inFlight = fileIndexRefreshTasks[rootDirectory] { + return await inFlight.value + } let rootURL = URL(fileURLWithPath: rootDirectory, isDirectory: true) - let index = TextBoxMentionCandidateIndex(candidates: Self.scanFiles(rootURL: rootURL)) + let scanTask = Task.detached(priority: .utility) { + TextBoxMentionCandidateIndex(candidates: Self.scanFiles(rootURL: rootURL)) + } + fileIndexRefreshTasks[rootDirectory] = scanTask + let index = await scanTask.value + fileIndexRefreshTasks[rootDirectory] = nil fileIndexesByRoot[rootDirectory] = CachedIndex(index: index, createdAt: now) return index } @@ -1728,7 +1819,7 @@ actor TextBoxMentionIndexStore { candidates.append(TextBoxMentionCandidate( title: "/\(skillName)", subtitle: Self.displayPath(path), - insertionText: TextBoxMentionMarkdown.link(label: "$\(skillName)", path: path), + targetPath: path, systemImageName: "sparkle.magnifyingglass", searchKey: "\(skillName) \(path)".lowercased(), priority: 0 @@ -1748,6 +1839,10 @@ actor TextBoxMentionIndexStore { } private static func scanFiles(rootURL: URL) -> [TextBoxMentionCandidate] { + if let ripgrepCandidates = scanFilesWithRipgrep(rootURL: rootURL) { + return ripgrepCandidates + } + let fileManager = FileManager.default guard let enumerator = fileManager.enumerator( at: rootURL, @@ -1765,7 +1860,7 @@ actor TextBoxMentionIndexStore { let name = standardizedURL.lastPathComponent let values = try? standardizedURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) if values?.isDirectory == true { - if skippedDirectoryNames.contains(name) { + if shouldSkipIndexedDirectoryName(name) { enumerator.skipDescendants() } continue @@ -1776,7 +1871,7 @@ actor TextBoxMentionIndexStore { candidates.append(TextBoxMentionCandidate( title: "@\(relativePath)", subtitle: Self.displayPath(standardizedURL.path), - insertionText: TextBoxMentionMarkdown.link(label: "@\(relativePath)", path: standardizedURL.path), + targetPath: standardizedURL.path, systemImageName: "doc", searchKey: "\(relativePath) \(name)".lowercased(), priority: min(relativePath.split(separator: "/").count, 20) @@ -1794,6 +1889,96 @@ actor TextBoxMentionIndexStore { } } + private static func scanFilesWithRipgrep(rootURL: URL) -> [TextBoxMentionCandidate]? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var arguments = [ + "rg", + "--files", + "--color", "never", + "--no-messages" + ] + // Apply the same skip list as the fallback enumerator. rg honors + // .gitignore in a git repo, but in a non-git root it would otherwise + // descend into node_modules/vendor/Pods/etc. and blow the file budget. + for name in skippedDirectoryNames.sorted() { + arguments.append("--glob") + arguments.append("!\(name)") + } + for suffix in skippedPackageDirectorySuffixes { + arguments.append("--iglob") + arguments.append("!*\(suffix)") + } + process.arguments = arguments + process.currentDirectoryURL = rootURL + + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + var candidates: [TextBoxMentionCandidate] = [] + candidates.reserveCapacity(min(maxIndexedFiles, 1024)) + + func appendCandidate(relativePath: String) { + guard !relativePath.isEmpty, candidates.count < maxIndexedFiles else { return } + let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardizedFileURL + let name = fileURL.lastPathComponent + candidates.append(TextBoxMentionCandidate( + title: "@\(relativePath)", + subtitle: displayPath(fileURL.path), + targetPath: fileURL.path, + systemImageName: "doc", + searchKey: "\(relativePath) \(name)".lowercased(), + priority: min(relativePath.split(separator: "/").count, 20) + )) + } + + let stdoutHandle = stdout.fileHandleForReading + var buffer = Data() + let newline: UInt8 = 10 + while candidates.count < maxIndexedFiles { + let chunk = stdoutHandle.readData(ofLength: 64 * 1024) + if chunk.isEmpty { break } + buffer.append(chunk) + while let newlineIndex = buffer.firstIndex(of: newline) { + let lineData = Data(buffer[..= maxIndexedFiles { + break + } + } + } + + let reachedLimit = candidates.count >= maxIndexedFiles + if reachedLimit, process.isRunning { + process.terminate() + } else if !buffer.isEmpty, + let relativePath = String(data: buffer, encoding: .utf8) { + appendCandidate(relativePath: relativePath) + } + + process.waitUntilExit() + guard reachedLimit || process.terminationStatus == 0 || process.terminationStatus == 1 else { + return nil + } + + return candidates.sorted { + if $0.priority != $1.priority { + return $0.priority < $1.priority + } + return $0.title.localizedStandardCompare($1.title) == .orderedAscending + } + } + private static func scanSkillFiles(rootURL: URL) -> [URL] { let fileManager = FileManager.default guard fileManager.fileExists(atPath: rootURL.path) else { return [] } @@ -1809,27 +1994,32 @@ actor TextBoxMentionIndexStore { includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants], errorHandler: { _, _ in true } - ) else { - return [] - } + ) else { return result } while let item = enumerator.nextObject() as? URL { let standardizedURL = item.standardizedFileURL - if standardizedURL.lastPathComponent == "SKILL.md" { - result.append(standardizedURL) - if result.count >= maxIndexedSkills { - break + let name = standardizedURL.lastPathComponent + let values = try? standardizedURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + if shouldSkipIndexedDirectoryName(name) { + enumerator.skipDescendants() + continue } - enumerator.skipDescendants() - continue + + let skillFile = standardizedURL.appendingPathComponent("SKILL.md", isDirectory: false) + if fileManager.fileExists(atPath: skillFile.path) { + result.append(skillFile.standardizedFileURL) + enumerator.skipDescendants() + } + } else if values?.isRegularFile == true, name == "SKILL.md" { + result.append(standardizedURL) } - if let values = try? standardizedURL.resourceValues(forKeys: [.isDirectoryKey]), - values.isDirectory == true, - skippedDirectoryNames.contains(standardizedURL.lastPathComponent) { - enumerator.skipDescendants() + if result.count >= maxIndexedSkills { + break } } + return result } @@ -1903,6 +2093,16 @@ actor TextBoxMentionIndexStore { (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + private static func shouldSkipIndexedDirectoryName(_ name: String) -> Bool { + if skippedDirectoryNames.contains(name) { + return true + } + let normalizedName = name.lowercased() + return skippedPackageDirectorySuffixes.contains { suffix in + normalizedName.hasSuffix(suffix.lowercased()) + } + } + private static func skillName(from skillURL: URL) -> String { guard let content = try? String(contentsOf: skillURL, encoding: .utf8) else { return skillURL.deletingLastPathComponent().lastPathComponent @@ -1962,30 +2162,62 @@ private final class TextBoxMentionCompletionController { @ObservationIgnored private var lookupTask: Task? @ObservationIgnored + private var suggestionsQuery: TextBoxMentionQuery? + @ObservationIgnored + private var suggestionsRootDirectory: String? + @ObservationIgnored var onStateChanged: (() -> Void)? var hasSuggestions: Bool { !suggestions.isEmpty } + var isActive: Bool { + activeQuery != nil + } + + var hasCurrentSuggestions: Bool { + hasSuggestions && + suggestionsQuery == activeQuery && + suggestionsRootDirectory == activeRootDirectory + } + + var hasStaleSuggestions: Bool { + hasSuggestions && !hasCurrentSuggestions + } + var selectedSuggestion: TextBoxMentionSuggestion? { + guard hasCurrentSuggestions else { return nil } guard suggestions.indices.contains(selectionIndex) else { return nil } return suggestions[selectionIndex] } func refresh(for query: TextBoxMentionQuery?, rootDirectory: String?) { + if query == nil { + guard activeQuery != nil || activeRootDirectory != nil || !suggestions.isEmpty else { return } + clear() + return + } + guard let query else { return } + guard activeQuery != query || activeRootDirectory != rootDirectory else { return } + let previousActiveQuery = activeQuery + let previousRootDirectory = activeRootDirectory activeQuery = query activeRootDirectory = rootDirectory selectionIndex = 0 + // 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 { + suggestions = [] + suggestionsQuery = nil + suggestionsRootDirectory = nil + } lookupTask?.cancel() - suggestions = [] onStateChanged?() - guard let query else { - return - } - lookupTask = Task { [weak self] in let suggestions = await TextBoxMentionIndexStore.shared.suggestions( for: query, @@ -1998,6 +2230,8 @@ private final class TextBoxMentionCompletionController { return } self.suggestions = suggestions + self.suggestionsQuery = query + self.suggestionsRootDirectory = rootDirectory self.selectionIndex = suggestions.isEmpty ? 0 : min(self.selectionIndex, suggestions.count - 1) self.onStateChanged?() } @@ -2005,15 +2239,18 @@ private final class TextBoxMentionCompletionController { } func moveSelection(delta: Int) { - guard !suggestions.isEmpty else { return } + guard hasCurrentSuggestions else { return } let count = suggestions.count selectionIndex = (selectionIndex + delta + count) % count + onStateChanged?() } func clear() { activeQuery = nil activeRootDirectory = nil suggestions = [] + suggestionsQuery = nil + suggestionsRootDirectory = nil selectionIndex = 0 lookupTask?.cancel() lookupTask = nil @@ -2034,6 +2271,8 @@ private final class TextBoxMentionCompletionController { activeQuery = query activeRootDirectory = nil suggestions = debugSuggestions + suggestionsQuery = query + suggestionsRootDirectory = nil selectionIndex = suggestions.isEmpty ? 0 : min(selectionIndex, suggestions.count - 1) onStateChanged?() } @@ -2041,48 +2280,98 @@ private final class TextBoxMentionCompletionController { var debugSuggestionCount: Int { suggestions.count } + + var debugHasCurrentSuggestions: Bool { + hasCurrentSuggestions + } #endif } private struct TextBoxMentionCompletionPopoverView: View { - let controller: TextBoxMentionCompletionController + let suggestions: [TextBoxMentionSuggestion] + let selectionIndex: Int + let searchTerm: String let onSelect: (TextBoxMentionSuggestion) -> Void var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(controller.suggestions.enumerated()), id: \.element.id) { index, suggestion in - Button { - onSelect(suggestion) - } label: { - HStack(spacing: 8) { - Image(systemName: suggestion.systemImageName) - .font(.system(size: 13, weight: .medium)) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text(suggestion.title) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, suggestion in + Button { + onSelect(suggestion) + } label: { + Text(Self.highlightedTitle(suggestion.title, query: searchTerm)) .font(.system(size: 12, weight: .semibold)) .lineLimit(1) - Text(suggestion.subtitle) - .font(.system(size: 10, weight: .regular)) - .foregroundStyle(.secondary) - .lineLimit(1) .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(index == selectionIndex ? Color.accentColor.opacity(0.24) : Color.clear) + } } - } - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(index == controller.selectionIndex ? Color.accentColor.opacity(0.24) : Color.clear) + .buttonStyle(.plain) + .id(index) } } - .buttonStyle(.plain) + .padding(4) + } + .onChange(of: selectionIndex) { _, newValue in + proxy.scrollTo(newValue, anchor: nil) } } - .padding(5) - .frame(width: 430) + .frame(width: 360) .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .transaction { transaction in + transaction.animation = nil + } + } + + private static func highlightedTitle(_ title: String, query: String) -> AttributedString { + var attributed = AttributedString(title) + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return attributed } + let positions = subsequenceMatchPositions(query: trimmedQuery, in: title) + guard !positions.isEmpty else { return attributed } + for position in positions { + guard let charIndex = title.index( + title.startIndex, + offsetBy: position, + limitedBy: title.endIndex + ), + charIndex < title.endIndex, + let attrLower = AttributedString.Index(charIndex, within: attributed) else { continue } + let nextChar = title.index(after: charIndex) + guard let attrUpper = AttributedString.Index(nextChar, within: attributed) else { continue } + attributed[attrLower.. [Int] { + let q = Array(query.lowercased()) + let t = Array(text.lowercased()) + guard !q.isEmpty, !t.isEmpty else { return [] } + var positions: [Int] = [] + var qIdx = 0 + for (tIdx, char) in t.enumerated() { + if qIdx < q.count, char == q[qIdx] { + positions.append(tIdx) + qIdx += 1 + if qIdx == q.count { return positions } + } + } + return qIdx == q.count ? positions : [] + } +} + +final class TextBoxMentionCompletionPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } } @MainActor @@ -3839,7 +4128,11 @@ struct TextBoxInputView: NSViewRepresentable { final class TextBoxInputTextView: NSTextView { var terminalTitle = "" - var completionRootDirectory: String? + var completionRootDirectory: String? { + didSet { + warmMentionCompletionIndexesIfNeeded() + } + } var onSubmit: () -> Void = {} var onEscape: () -> Void = {} var onFocusTextBox: () -> Void = {} @@ -3865,8 +4158,11 @@ final class TextBoxInputTextView: NSTextView { private var attachmentKeyDownMonitor: Any? private var preserveAttachmentFocusOnNextResign = false private var attachmentUploadInvalidationGeneration: UInt64 = 0 - private var mentionCompletionPopover: NSPopover? + private var mentionCompletionPanel: TextBoxMentionCompletionPanel? + private var mentionCompletionPanelHost: NSHostingView? private var mentionCompletionControllerStorage: TextBoxMentionCompletionController? + private var warmedMentionCompletionRootDirectory: String? + private var mentionCompletionWarmupTask: Task? private var pendingUndoableAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var pendingAutomaticAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var suppressAutomaticAttachmentFileCleanup = false @@ -3887,6 +4183,7 @@ final class TextBoxInputTextView: NSTextView { } deinit { + mentionCompletionWarmupTask?.cancel() dismissMentionCompletions() removeAttachmentKeyDownMonitor() discardUndoHistoryAndCleanupPendingAttachmentFiles() @@ -3898,6 +4195,7 @@ final class TextBoxInputTextView: NSTextView { super.viewDidMoveToWindow() if window == nil { invalidatePendingAttachmentUploads() + dismissMentionCompletions() } else { notifyMovedToWindowIfAttached() } @@ -4704,37 +5002,80 @@ final class TextBoxInputTextView: NSTextView { } func refreshMentionCompletions() { + let query = TextBoxMentionCompletionDetector.query( + in: attributedString().string, + selectedRange: selectedRange() + ) mentionCompletionController.refresh( - for: TextBoxMentionCompletionDetector.query( - in: attributedString().string, - selectedRange: selectedRange() - ), + for: query, rootDirectory: completionRootDirectory ) } + private func warmMentionCompletionIndexesIfNeeded() { + let rootDirectory = completionRootDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + let cacheKey = rootDirectory?.isEmpty == false ? rootDirectory : nil + guard warmedMentionCompletionRootDirectory != cacheKey else { return } + warmedMentionCompletionRootDirectory = cacheKey + mentionCompletionWarmupTask?.cancel() + mentionCompletionWarmupTask = Task { + await TextBoxMentionIndexStore.shared.warmIndexes(rootDirectory: cacheKey) + } + } + private func handleMentionCompletionKeyEvent(_ event: NSEvent) -> Bool { - guard mentionCompletionController.hasSuggestions else { return false } + guard mentionCompletionController.isActive else { return false } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard !flags.contains(.command), - !flags.contains(.control), !flags.contains(.option) else { return false } + if flags.contains(.control) { + guard let key = controlKey(for: event) else { return false } + // Only claim the navigation keys once there are rows to move through; + // otherwise (active query still loading or zero hits) let them fall + // through to normal text editing instead of being silently swallowed. + guard mentionCompletionController.hasCurrentSuggestions else { return false } + switch key { + case "p", "k": + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + mentionCompletionController.moveSelection(delta: -1) + return true + case "n", "j": + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + mentionCompletionController.moveSelection(delta: 1) + return true + default: + return false + } + } + switch Int(event.keyCode) { case kVK_UpArrow: + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasCurrentSuggestions else { return false } mentionCompletionController.moveSelection(delta: -1) return true case kVK_DownArrow: + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasCurrentSuggestions else { return false } mentionCompletionController.moveSelection(delta: 1) return true case kVK_Return, kVK_ANSI_KeypadEnter: guard !flags.contains(.shift) else { return false } + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + if shouldBypassMentionCompletionKeyboardAcceptance() { return false } + if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case kVK_Tab: + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + if shouldBypassMentionCompletionKeyboardAcceptance() { return false } + if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case kVK_Escape: + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasSuggestions else { return false } dismissMentionCompletions() return true default: @@ -4743,19 +5084,28 @@ final class TextBoxInputTextView: NSTextView { } private func handleMentionCompletionCommand(_ commandSelector: Selector) -> Bool { - guard mentionCompletionController.hasSuggestions else { return false } + guard mentionCompletionController.isActive else { return false } switch commandSelector { case #selector(NSResponder.moveUp(_:)): + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasCurrentSuggestions else { return false } mentionCompletionController.moveSelection(delta: -1) return true case #selector(NSResponder.moveDown(_:)): + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasCurrentSuggestions else { return false } mentionCompletionController.moveSelection(delta: 1) return true case #selector(NSResponder.insertNewline(_:)), #selector(NSResponder.insertTab(_:)): + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + if shouldBypassMentionCompletionKeyboardAcceptance() { return false } + if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case #selector(NSResponder.cancelOperation(_:)): + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + guard mentionCompletionController.hasSuggestions else { return false } dismissMentionCompletions() return true default: @@ -4763,10 +5113,35 @@ final class TextBoxInputTextView: NSTextView { } } + private func shouldBypassHiddenMentionCompletionKeyboardInteraction() -> Bool { + guard let window else { return false } + guard NSApp.isActive, + window.isKeyWindow, + window.firstResponder === self, + mentionCompletionPanel?.isVisible == true else { + dismissMentionCompletions() + return true + } + return false + } + + private func shouldBypassMentionCompletionKeyboardAcceptance() -> Bool { + guard let query = mentionCompletionController.activeQuery, + query.kind == .skill, + query.query.isEmpty else { + return false + } + dismissMentionCompletions() + return true + } + @discardableResult private func acceptMentionCompletion(_ explicitSuggestion: TextBoxMentionSuggestion? = nil) -> Bool { - guard let query = mentionCompletionController.activeQuery, + guard mentionCompletionController.hasCurrentSuggestions, + let query = mentionCompletionController.activeQuery, let suggestion = explicitSuggestion ?? mentionCompletionController.selectedSuggestion, + explicitSuggestion == nil || + mentionCompletionController.suggestions.contains(where: { $0.id == suggestion.id }), isValidSelectedRange(query.range), shouldChangeText(in: query.range, replacementString: suggestion.insertionText) else { return false @@ -4808,39 +5183,113 @@ final class TextBoxInputTextView: NSTextView { } private func syncMentionCompletionPopover() { - guard mentionCompletionController.hasSuggestions, + guard mentionCompletionController.hasSuggestions else { + dismissMentionCompletionPopoverOnly() + return + } + guard NSApp.isActive, window?.firstResponder === self, + let parentWindow = window, + parentWindow.isKeyWindow, let anchorRect = mentionCompletionAnchorRect() else { dismissMentionCompletionPopoverOnly() return } - let popover = mentionCompletionPopover ?? makeMentionCompletionPopover() - popover.contentSize = NSSize( - width: 430, - height: CGFloat(mentionCompletionController.suggestions.count) * 36 + 10 + let rowCount = mentionCompletionController.suggestions.count + let maxVisibleRows = 12 + let visibleRows = min(rowCount, maxVisibleRows) + let rowHeight: CGFloat = 25 + let contentSize = NSSize( + width: 360, + height: CGFloat(visibleRows) * rowHeight + 8 ) - if !popover.isShown { - popover.show(relativeTo: anchorRect, of: self, preferredEdge: .maxY) - window?.makeFirstResponder(self) + let host: NSHostingView + if let existingHost = mentionCompletionPanelHost { + existingHost.rootView = mentionCompletionPopoverView() + host = existingHost + } else { + host = NSHostingView(rootView: mentionCompletionPopoverView()) + host.translatesAutoresizingMaskIntoConstraints = true + host.autoresizingMask = [] + mentionCompletionPanelHost = host + } + host.frame = NSRect(origin: .zero, size: contentSize) + + let panel = mentionCompletionPanel ?? makeMentionCompletionPanel(host: host) + if panel.contentView !== host { + panel.contentView = host + } + panel.setContentSize(contentSize) + panel.setFrameOrigin(mentionCompletionPanelOrigin( + anchorRect: anchorRect, + contentSize: contentSize + )) + + if panel.parent !== parentWindow { + panel.parent?.removeChildWindow(panel) + parentWindow.addChildWindow(panel, ordered: .above) + } + if !panel.isVisible { + panel.orderFront(nil) } } - private func makeMentionCompletionPopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = false - popover.contentViewController = NSHostingController( - rootView: TextBoxMentionCompletionPopoverView( - controller: mentionCompletionController, - onSelect: { [weak self] suggestion in - self?.window?.makeFirstResponder(self) - self?.acceptMentionCompletion(suggestion) - } - ) + private func makeMentionCompletionPanel( + host: NSHostingView + ) -> TextBoxMentionCompletionPanel { + let panel = TextBoxMentionCompletionPanel( + contentRect: NSRect(origin: .zero, size: host.fittingSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.hidesOnDeactivate = true + panel.becomesKeyOnlyIfNeeded = true + panel.worksWhenModal = true + panel.level = .popUpMenu + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.collectionBehavior = [.transient, .fullScreenAuxiliary, .moveToActiveSpace] + panel.contentView = host + mentionCompletionPanel = panel + return panel + } + + private func mentionCompletionPanelOrigin( + anchorRect: NSRect, + contentSize: NSSize + ) -> NSPoint { + let anchorInWindow = convert(anchorRect, to: nil) + guard let window else { + return .zero + } + let anchorOnScreen = window.convertToScreen(anchorInWindow) + let screenFrame = window.screen?.visibleFrame ?? anchorOnScreen + var x = anchorOnScreen.minX + let gap: CGFloat = 4 + var y = anchorOnScreen.minY - contentSize.height - gap + if y < screenFrame.minY + 8 { + y = anchorOnScreen.maxY + gap + } + let maxX = screenFrame.maxX - contentSize.width - 8 + if x > maxX { x = max(screenFrame.minX + 8, maxX) } + if x < screenFrame.minX + 8 { x = screenFrame.minX + 8 } + return NSPoint(x: x, y: y) + } + + private func mentionCompletionPopoverView() -> TextBoxMentionCompletionPopoverView { + TextBoxMentionCompletionPopoverView( + suggestions: mentionCompletionController.suggestions, + selectionIndex: mentionCompletionController.selectionIndex, + searchTerm: mentionCompletionController.activeQuery?.query ?? "", + onSelect: { [weak self] suggestion in + self?.window?.makeFirstResponder(self) + self?.acceptMentionCompletion(suggestion) + } ) - mentionCompletionPopover = popover - return popover } private func mentionCompletionAnchorRect() -> NSRect? { @@ -4860,7 +5309,8 @@ final class TextBoxInputTextView: NSTextView { ) } - let cursor = min(max(0, selectedRange().location), length) + let queryCursor = mentionCompletionController.activeQuery.map { NSMaxRange($0.range) } + let cursor = min(max(0, queryCursor ?? selectedRange().location), length) let characterLocation = max(0, min(cursor, length - 1)) let glyphRange = layoutManager.glyphRange( forCharacterRange: NSRange(location: characterLocation, length: 1), @@ -4883,8 +5333,12 @@ final class TextBoxInputTextView: NSTextView { } private func dismissMentionCompletionPopoverOnly() { - mentionCompletionPopover?.performClose(nil) - mentionCompletionPopover = nil + if let panel = mentionCompletionPanel { + panel.parent?.removeChildWindow(panel) + panel.orderOut(nil) + } + mentionCompletionPanel = nil + mentionCompletionPanelHost = nil } private func moveInsertionPointLeft() { @@ -4951,6 +5405,18 @@ final class TextBoxInputTextView: NSTextView { func debugMentionSuggestionCount() -> Int { mentionCompletionController.debugSuggestionCount } + + func debugMentionSuggestionsAreCurrent() -> Bool { + mentionCompletionController.debugHasCurrentSuggestions + } + + func debugAcceptMentionCompletion() -> Bool { + acceptMentionCompletion() + } + + func debugAcceptMentionCompletion(suggestion: TextBoxMentionSuggestion) -> Bool { + acceptMentionCompletion(suggestion) + } #endif private func handleConfiguredTextBoxShortcut(_ event: NSEvent) -> Bool { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 5668bd9799..a06255fcba 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7061,16 +7061,22 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(dollarSkillQuery?.range, NSRange(location: 4, length: 12)) let bareSlashPrompt = "cd /" - XCTAssertNil(TextBoxMentionCompletionDetector.query( + let bareSlashQuery = TextBoxMentionCompletionDetector.query( in: bareSlashPrompt, selectedRange: NSRange(location: (bareSlashPrompt as NSString).length, length: 0) - )) + ) + XCTAssertEqual(bareSlashQuery?.kind, .skill) + XCTAssertEqual(bareSlashQuery?.trigger, "/") + XCTAssertEqual(bareSlashQuery?.query, "") let bareDollarPrompt = "echo $" - XCTAssertNil(TextBoxMentionCompletionDetector.query( + let bareDollarQuery = TextBoxMentionCompletionDetector.query( in: bareDollarPrompt, selectedRange: NSRange(location: (bareDollarPrompt as NSString).length, length: 0) - )) + ) + XCTAssertEqual(bareDollarQuery?.kind, .skill) + XCTAssertEqual(bareDollarQuery?.trigger, "$") + XCTAssertEqual(bareDollarQuery?.query, "") let emailPrompt = "mail lawrence@example.com" XCTAssertNil(TextBoxMentionCompletionDetector.query( @@ -7115,6 +7121,95 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@Sources/TextBoxInput.swift](") == true) } + func testTextBoxMentionFileSuggestionsReturnRootFilesForEmptyQuery() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-empty-file-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try "notes".write( + to: root.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 1), + query: "", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertEqual(suggestions.first?.title, "@README.md") + XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@README.md](") == true) + } + + func testTextBoxMentionFileSuggestionsSkipPackageContents() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-package-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let packageDirectory = root + .appendingPathComponent("Dependencies", isDirectory: true) + .appendingPathComponent("GhosttyKit.xcframework", isDirectory: true) + try fileManager.createDirectory(at: packageDirectory, withIntermediateDirectories: true) + try "internal".write( + to: packageDirectory.appendingPathComponent("InternalNeedle.swift"), + atomically: true, + encoding: .utf8 + ) + + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 15), + query: "InternalNeedle", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertFalse(suggestions.contains { $0.title.contains("InternalNeedle.swift") }) + } + + func testTextBoxMentionFileSuggestionsKeepCaseVariantProjectDirectories() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-library-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let libraryDirectory = root.appendingPathComponent("library", isDirectory: true) + try fileManager.createDirectory(at: libraryDirectory, withIntermediateDirectories: true) + try "valid".write( + to: libraryDirectory.appendingPathComponent("VisibleNeedle.swift"), + atomically: true, + encoding: .utf8 + ) + + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 14), + query: "VisibleNeedle", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertTrue(suggestions.contains { $0.title == "@library/VisibleNeedle.swift" }) + } + func testTextBoxMentionFileSuggestionsRefreshCachedMisses() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent( @@ -7189,35 +7284,186 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(suggestions.first?.title, "$sample-dollar-skill") XCTAssertEqual(suggestions.first?.systemImageName, "sparkle.magnifyingglass") - XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[$sample-dollar-skill](") == true) + // The $ trigger inserts the bare skill reference (not a markdown link), + // unlike the / and @ triggers. + XCTAssertEqual(suggestions.first?.insertionText, "$sample-dollar-skill") } - func testTextBoxMentionRefreshClearsStaleSuggestionsBeforeLookup() { + func testTextBoxMentionSkillSuggestionsUseTypedSlashTriggerForEmptyQuery() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-slash-skills-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let skillDirectory = root + .appendingPathComponent("skills", isDirectory: true) + .appendingPathComponent("sample-slash-skill", isDirectory: true) + try fileManager.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + try "name: sample-slash-skill\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: 1), + query: "", + trigger: "/" + ), + rootDirectory: root.path + ) + + // An empty query returns the whole skill corpus, which also includes the + // machine's global skill roots (~/.codex/skills, etc.), so the temp skill + // is not guaranteed to sort first. Assert it is present with the typed + // trigger rather than asserting its position. + let slashSkill = suggestions.first { $0.title == "/sample-slash-skill" } + XCTAssertNotNil(slashSkill) + XCTAssertTrue(slashSkill?.insertionText.hasPrefix("[/sample-slash-skill](") == true) + } + + func testTextBoxMentionSkillSuggestionsFindNestedSkillPacks() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-nested-skills-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let skillDirectory = root + .appendingPathComponent("skills", isDirectory: true) + .appendingPathComponent("team", isDirectory: true) + .appendingPathComponent("nested-skill", isDirectory: true) + try fileManager.createDirectory(at: skillDirectory, withIntermediateDirectories: true) + try "name: nested-skill\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: 13), + query: "nested-skill", + trigger: "/" + ), + rootDirectory: root.path + ) + + XCTAssertEqual(suggestions.first?.title, "/nested-skill") + XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[/nested-skill](") == true) + } + + func testTextBoxMentionRefreshKeepsRowsOnSameTriggerEditButClearsOnTriggerChange() { let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) textView.string = "@a" textView.setSelectedRange(NSRange(location: 2, length: 0)) + let staleSuggestion = TextBoxMentionSuggestion( + id: "alpha", + title: "@alpha.txt", + subtitle: "alpha.txt", + insertionText: "[@alpha.txt](/tmp/alpha.txt)", + systemImageName: "doc" + ) textView.debugSetMentionCompletionState( query: TextBoxMentionQuery(kind: .file, range: NSRange(location: 0, length: 2), query: "a"), - suggestions: [ - TextBoxMentionSuggestion( - id: "alpha", - title: "@alpha.txt", - subtitle: "alpha.txt", - insertionText: "[@alpha.txt](/tmp/alpha.txt)", - systemImageName: "doc" - ) - ] + suggestions: [staleSuggestion] ) XCTAssertEqual(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.refreshMentionCompletions() + XCTAssertEqual(textView.debugMentionSuggestionCount(), 1) + XCTAssertFalse(textView.debugMentionSuggestionsAreCurrent()) + XCTAssertFalse(textView.debugAcceptMentionCompletion()) + XCTAssertFalse(textView.debugAcceptMentionCompletion(suggestion: staleSuggestion)) + XCTAssertEqual(textView.string, "@z") + var submitCount = 0 + textView.onSubmit = { submitCount += 1 } + textView.doCommand(by: #selector(NSResponder.insertNewline(_:))) + XCTAssertEqual(submitCount, 0) + XCTAssertEqual(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)) + textView.refreshMentionCompletions() XCTAssertEqual(textView.debugMentionSuggestionCount(), 0) } + func testTextBoxMentionEscapeFallsThroughWhenQueryHasNoSuggestions() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "@missing" + textView.setSelectedRange(NSRange(location: 8, length: 0)) + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery(kind: .file, range: NSRange(location: 0, length: 8), query: "missing"), + suggestions: [] + ) + var escapeCount = 0 + textView.onEscape = { escapeCount += 1 } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: UInt16(kVK_Escape), + windowNumber: 0 + ) else { + XCTFail("Failed to construct Escape event") + return + } + + textView.keyDown(with: escapeEvent) + XCTAssertEqual(escapeCount, 1) + } + + func testTextBoxMentionBareSkillTriggerReturnSubmitsInsteadOfAcceptingFirstSuggestion() { + let scenarios: [(text: String, range: NSRange, trigger: Character, insertionText: String)] = [ + ("cd /", NSRange(location: 3, length: 1), "/", "[/sample-skill](/tmp/sample-skill/SKILL.md)"), + ("echo $", NSRange(location: 5, length: 1), "$", "$sample-skill") + ] + + for scenario in scenarios { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = scenario.text + textView.setSelectedRange(NSRange(location: (scenario.text as NSString).length, length: 0)) + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: scenario.range, + query: "", + trigger: scenario.trigger + ), + suggestions: [ + TextBoxMentionSuggestion( + id: "\(scenario.trigger):/tmp/sample-skill/SKILL.md", + title: "\(scenario.trigger)sample-skill", + subtitle: "/tmp/sample-skill/SKILL.md", + insertionText: scenario.insertionText, + systemImageName: "sparkle.magnifyingglass" + ) + ] + ) + var submitCount = 0 + textView.onSubmit = { submitCount += 1 } + + textView.doCommand(by: #selector(NSResponder.insertNewline(_:))) + + XCTAssertEqual(submitCount, 1) + XCTAssertEqual(textView.string, scenario.text) + XCTAssertEqual(textView.debugMentionSuggestionCount(), 0) + } + } + func testTextBoxSubmitUsesPastePayloadAndSeparateReturn() throws { XCTAssertEqual(TextBoxSubmit.submittedPasteText(for: "hello"), "hello") XCTAssertEqual(TextBoxSubmit.submittedPasteText(for: "hello\nworld"), "hello\nworld") From 72033d6870b1fdf48ff18044a50e6623b4aeac3d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 17:09:08 -0700 Subject: [PATCH 2/7] Reposition textbox completions on window changes --- Sources/TextBoxInput.swift | 114 ++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 61e3aaecc5..700889283e 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -4163,6 +4163,9 @@ final class TextBoxInputTextView: NSTextView { private var mentionCompletionControllerStorage: TextBoxMentionCompletionController? private var warmedMentionCompletionRootDirectory: String? private var mentionCompletionWarmupTask: Task? + private var mentionCompletionWindowObserverTokens: [NSObjectProtocol] = [] + private weak var mentionCompletionObservedWindow: NSWindow? + private var mentionCompletionRepositionIsScheduled = false private var pendingUndoableAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var pendingAutomaticAttachmentFileCleanup: [String: TextBoxAttachment] = [:] private var suppressAutomaticAttachmentFileCleanup = false @@ -4184,6 +4187,7 @@ final class TextBoxInputTextView: NSTextView { deinit { mentionCompletionWarmupTask?.cancel() + removeMentionCompletionWindowObservers() dismissMentionCompletions() removeAttachmentKeyDownMonitor() discardUndoHistoryAndCleanupPendingAttachmentFiles() @@ -4198,6 +4202,9 @@ final class TextBoxInputTextView: NSTextView { dismissMentionCompletions() } else { notifyMovedToWindowIfAttached() + if mentionCompletionPanel?.isVisible == true { + scheduleMentionCompletionPanelReposition() + } } layer?.borderColor = textColor?.withAlphaComponent(0.24).cgColor } @@ -5195,6 +5202,7 @@ final class TextBoxInputTextView: NSTextView { dismissMentionCompletionPopoverOnly() return } + updateMentionCompletionWindowObservers(for: parentWindow) let rowCount = mentionCompletionController.suggestions.count let maxVisibleRows = 12 @@ -5221,10 +5229,13 @@ final class TextBoxInputTextView: NSTextView { panel.contentView = host } panel.setContentSize(contentSize) - panel.setFrameOrigin(mentionCompletionPanelOrigin( + let targetOrigin = mentionCompletionPanelOrigin( anchorRect: anchorRect, contentSize: contentSize - )) + ) + if mentionCompletionPanelOriginNeedsUpdate(from: panel.frame.origin, to: targetOrigin) { + panel.setFrameOrigin(targetOrigin) + } if panel.parent !== parentWindow { panel.parent?.removeChildWindow(panel) @@ -5258,6 +5269,104 @@ final class TextBoxInputTextView: NSTextView { return panel } + private func updateMentionCompletionWindowObservers(for parentWindow: NSWindow) { + if mentionCompletionObservedWindow === parentWindow, + !mentionCompletionWindowObserverTokens.isEmpty { + return + } + + removeMentionCompletionWindowObservers() + mentionCompletionObservedWindow = parentWindow + + let notificationNames: [Notification.Name] = [ + NSWindow.didMoveNotification, + NSWindow.didResizeNotification, + NSWindow.didChangeScreenNotification + ] + let notificationCenter = NotificationCenter.default + mentionCompletionWindowObserverTokens = notificationNames.map { notificationName in + notificationCenter.addObserver( + forName: notificationName, + object: parentWindow, + queue: .main + ) { [weak self] _ in + self?.scheduleMentionCompletionPanelReposition() + } + } + } + + private func removeMentionCompletionWindowObservers() { + let notificationCenter = NotificationCenter.default + for observerToken in mentionCompletionWindowObserverTokens { + notificationCenter.removeObserver(observerToken) + } + mentionCompletionWindowObserverTokens = [] + mentionCompletionObservedWindow = nil + mentionCompletionRepositionIsScheduled = false + } + + private func scheduleMentionCompletionPanelReposition() { + guard mentionCompletionPanel?.isVisible == true, + !mentionCompletionRepositionIsScheduled else { + return + } + mentionCompletionRepositionIsScheduled = true + Task { @MainActor [weak self] in + guard let self, + self.mentionCompletionRepositionIsScheduled else { + return + } + self.mentionCompletionRepositionIsScheduled = false + self.repositionMentionCompletionPanelIfNeeded() + } + } + + private func repositionMentionCompletionPanelIfNeeded() { + guard mentionCompletionController.hasSuggestions, + let panel = mentionCompletionPanel, + panel.isVisible, + NSApp.isActive, + window?.firstResponder === self, + let parentWindow = window, + parentWindow.isKeyWindow, + let anchorRect = mentionCompletionAnchorRect(), + let contentSize = mentionCompletionPanelContentSize(panel), + contentSize.width > 0, + contentSize.height > 0 else { + dismissMentionCompletionPopoverOnly() + return + } + + updateMentionCompletionWindowObservers(for: parentWindow) + if panel.parent !== parentWindow { + panel.parent?.removeChildWindow(panel) + parentWindow.addChildWindow(panel, ordered: .above) + } + + let targetOrigin = mentionCompletionPanelOrigin( + anchorRect: anchorRect, + contentSize: contentSize + ) + if mentionCompletionPanelOriginNeedsUpdate(from: panel.frame.origin, to: targetOrigin) { + panel.setFrameOrigin(targetOrigin) + } + } + + private func mentionCompletionPanelContentSize(_ panel: TextBoxMentionCompletionPanel) -> NSSize? { + if let contentView = panel.contentView { + return contentView.bounds.size + } + return panel.contentRect(forFrameRect: panel.frame).size + } + + private func mentionCompletionPanelOriginNeedsUpdate( + from currentOrigin: NSPoint, + to targetOrigin: NSPoint + ) -> Bool { + abs(currentOrigin.x - targetOrigin.x) > 0.5 || + abs(currentOrigin.y - targetOrigin.y) > 0.5 + } + private func mentionCompletionPanelOrigin( anchorRect: NSRect, contentSize: NSSize @@ -5333,6 +5442,7 @@ final class TextBoxInputTextView: NSTextView { } private func dismissMentionCompletionPopoverOnly() { + removeMentionCompletionWindowObservers() if let panel = mentionCompletionPanel { panel.parent?.removeChildWindow(panel) panel.orderOut(nil) From 41a37024f0a0a9ebdb1fc50f49fa395f13511f98 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 18:05:06 -0700 Subject: [PATCH 3/7] Improve textbox mention search indexing --- Sources/TextBoxInput.swift | 475 ++++++++++++++---- .../AppDelegateShortcutRoutingTests.swift | 113 +++++ 2 files changed, 492 insertions(+), 96 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 700889283e..6f6170923f 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -1600,11 +1600,16 @@ private struct TextBoxMentionCandidate: Sendable { } private struct TextBoxMentionCandidateIndex: Sendable { + private static let nucleoProbeLimitMultiplier = 4 + private static let minimumNucleoProbeLimit = 512 + private let corpus: [CommandPaletteSearchCorpusEntry] + private let corpusByTargetPath: [String: CommandPaletteSearchCorpusEntry] + private let emptyQueryCandidates: [TextBoxMentionCandidate] private let nucleoIndex: CommandPaletteNucleoSearchIndex? init(candidates: [TextBoxMentionCandidate]) { - corpus = candidates.map { candidate in + let entries = candidates.map { candidate in CommandPaletteSearchCorpusEntry( payload: candidate, rank: candidate.priority, @@ -1616,39 +1621,110 @@ private struct TextBoxMentionCandidateIndex: Sendable { ] ) } - nucleoIndex = corpus.count >= 32 ? CommandPaletteNucleoSearchIndex(entries: corpus) : nil + corpus = entries + corpusByTargetPath = CommandPaletteSearchOrchestrator.firstValueDictionary( + entries, + keyedBy: { $0.payload.targetPath } + ) + emptyQueryCandidates = entries + .sorted { lhs, rhs in + if lhs.rank != rhs.rank { + return lhs.rank < rhs.rank + } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + .map(\.payload) + nucleoIndex = entries.count >= 32 ? CommandPaletteNucleoSearchIndex(entries: entries) : nil } - func rankedCandidates(matching rawQuery: String, limit: Int) -> [TextBoxMentionCandidate] { + func rankedCandidates( + matching rawQuery: String, + limit: Int, + shouldCancel: @escaping () -> Bool = { false } + ) -> [TextBoxMentionCandidate] { + guard limit > 0, !shouldCancel() else { return [] } let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { - return corpus - .sorted { lhs, rhs in - if lhs.rank != rhs.rank { - return lhs.rank < rhs.rank - } - return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending - } - .prefix(limit) - .map(\.payload) + 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 shouldCancel() { return [] } + let probedCorpus = nucleoResults.compactMap { result in + corpusByTargetPath[result.payload.targetPath] + } + let swiftMatches = Self.swiftRankedCandidates( + entries: probedCorpus, + query: query, + limit: limit, + shouldCancel: shouldCancel + ) + return Self.mergedRankedCandidates( + swiftMatches, + nucleoMatches: nucleoResults.map(\.payload), + limit: limit + ) } - if let nucleoResults = nucleoIndex?.search( + return Self.swiftRankedCandidates( + entries: corpus, query: query, - resultLimit: limit, - historyBoost: { _, _ in 0 } - ) { - return nucleoResults.map(\.payload) - } + limit: limit, + shouldCancel: shouldCancel + ) + } - return CommandPaletteSearchEngine.search( - entries: corpus, + private static func nucleoProbeLimit(corpusCount: Int, requestedLimit: Int) -> Int { + let expandedLimit = requestedLimit * Self.nucleoProbeLimitMultiplier + return min(corpusCount, max(expandedLimit, Self.minimumNucleoProbeLimit)) + } + + private static func swiftRankedCandidates( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + limit: Int, + shouldCancel: @escaping () -> Bool + ) -> [TextBoxMentionCandidate] { + CommandPaletteSearchEngine.search( + entries: entries, query: query, resultLimit: limit, - historyBoost: { _, _ in 0 } + historyBoost: { _, _ in 0 }, + shouldCancel: shouldCancel ) .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 + } + merged.append(candidate) + } + + for candidate in swiftMatches { + append(candidate) + } + for candidate in nucleoMatches { + append(candidate) + } + return merged + } } actor TextBoxMentionIndexStore { @@ -1660,6 +1736,8 @@ actor TextBoxMentionIndexStore { } private static let fileIndexTTL: TimeInterval = 30 + private static let directorySeedBatchSize = 128 + private static let maxIndexedDirectories = 2000 private static let maxIndexedFiles = 6000 private static let maxIndexedSkills = 800 private static let suggestionLimit = 500 @@ -1707,68 +1785,49 @@ actor TextBoxMentionIndexStore { return await fileSuggestions(for: query, rootDirectory: rootDirectory) case .skill: let index = skillIndex(rootDirectory: Self.normalizedDirectory(rootDirectory)) - return index.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) + return index.rankedCandidates( + matching: query.query, + limit: Self.suggestionLimit, + shouldCancel: { Task.isCancelled } + ) .map { $0.suggestion(trigger: query.trigger) } } } - func warmIndexes(rootDirectory: String?) { + func warmIndexes(rootDirectory: String?) async { let normalizedRootDirectory = Self.normalizedDirectory(rootDirectory) _ = skillIndex(rootDirectory: normalizedRootDirectory) + if let normalizedRootDirectory { + _ = await fileIndex(rootDirectory: normalizedRootDirectory, now: Date()) + } } private func fileSuggestions( for query: TextBoxMentionQuery, rootDirectory: String ) async -> [TextBoxMentionSuggestion] { - if query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return Self.scanRootFiles(rootURL: URL(fileURLWithPath: rootDirectory, isDirectory: true)) - .prefix(Self.suggestionLimit) - .map { $0.suggestion(trigger: query.trigger) } - } - let now = Date() let index = await fileIndex(rootDirectory: rootDirectory, now: now) - var matches = index.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) - if matches.isEmpty, !query.query.isEmpty { + if Task.isCancelled { return [] } + + var matches = index.rankedCandidates( + matching: query.query, + limit: Self.suggestionLimit, + shouldCancel: { Task.isCancelled } + ) + if matches.isEmpty, !query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let refreshed = await refreshFileIndex(rootDirectory: rootDirectory, now: now) - matches = refreshed.rankedCandidates(matching: query.query, limit: Self.suggestionLimit) + if Task.isCancelled { return [] } + matches = refreshed.rankedCandidates( + matching: query.query, + limit: Self.suggestionLimit, + shouldCancel: { Task.isCancelled } + ) } return matches .map { $0.suggestion(trigger: query.trigger) } } - private static func scanRootFiles(rootURL: URL) -> [TextBoxMentionCandidate] { - let fileManager = FileManager.default - let rootPath = rootURL.standardizedFileURL.path - guard let children = try? fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return [] - } - - return children.compactMap { child -> TextBoxMentionCandidate? in - let standardizedURL = child.standardizedFileURL - guard (try? standardizedURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { - return nil - } - let relativePath = relativePath(for: standardizedURL.path, rootPath: rootPath) - return TextBoxMentionCandidate( - title: "@\(relativePath)", - subtitle: displayPath(standardizedURL.path), - targetPath: standardizedURL.path, - systemImageName: "doc", - searchKey: "\(relativePath) \(standardizedURL.lastPathComponent)".lowercased(), - priority: 0 - ) - } - .sorted { - $0.title.localizedStandardCompare($1.title) == .orderedAscending - } - } - private func fileIndex( rootDirectory: String, now: Date @@ -1854,7 +1913,24 @@ actor TextBoxMentionIndexStore { } let rootPath = rootURL.standardizedFileURL.path - var candidates: [TextBoxMentionCandidate] = [] + var directoryCandidates: [TextBoxMentionCandidate] = [] + var fileCandidates: [TextBoxMentionCandidate] = [] + var seenDirectoryRelativePaths = Set() + directoryCandidates.reserveCapacity(min(maxIndexedDirectories, 256)) + fileCandidates.reserveCapacity(min(maxIndexedFiles, 1024)) + + func appendDirectoryCandidate(relativePath: String, directoryURL: URL) { + guard !relativePath.isEmpty, + directoryCandidates.count < maxIndexedDirectories, + seenDirectoryRelativePaths.insert(relativePath).inserted else { + return + } + directoryCandidates.append(Self.directoryCandidate( + relativePath: relativePath, + directoryURL: directoryURL + )) + } + while let item = enumerator.nextObject() as? URL { let standardizedURL = item.standardizedFileURL let name = standardizedURL.lastPathComponent @@ -1862,31 +1938,30 @@ actor TextBoxMentionIndexStore { if values?.isDirectory == true { if shouldSkipIndexedDirectoryName(name) { enumerator.skipDescendants() + continue } + appendDirectoryCandidate( + relativePath: Self.relativePath(for: standardizedURL.path, rootPath: rootPath), + directoryURL: standardizedURL + ) continue } guard values?.isRegularFile == true else { continue } let relativePath = Self.relativePath(for: standardizedURL.path, rootPath: rootPath) - candidates.append(TextBoxMentionCandidate( - title: "@\(relativePath)", - subtitle: Self.displayPath(standardizedURL.path), - targetPath: standardizedURL.path, - systemImageName: "doc", - searchKey: "\(relativePath) \(name)".lowercased(), - priority: min(relativePath.split(separator: "/").count, 20) - )) + if fileCandidates.count < maxIndexedFiles { + fileCandidates.append(Self.fileCandidate( + relativePath: relativePath, + fileURL: standardizedURL, + fileName: name + )) + } - if candidates.count >= maxIndexedFiles { + if fileCandidates.count >= maxIndexedFiles { break } } - return candidates.sorted { - if $0.priority != $1.priority { - return $0.priority < $1.priority - } - return $0.title.localizedStandardCompare($1.title) == .orderedAscending - } + return sortedFileSystemCandidates(directoryCandidates + fileCandidates) } private static func scanFilesWithRipgrep(rootURL: URL) -> [TextBoxMentionCandidate]? { @@ -1922,48 +1997,77 @@ actor TextBoxMentionIndexStore { return nil } - var candidates: [TextBoxMentionCandidate] = [] - candidates.reserveCapacity(min(maxIndexedFiles, 1024)) + let directorySeed = scanDirectoryCandidateSeed(rootURL: rootURL) + var directoryCandidates = directorySeed.candidates + var fileCandidates: [TextBoxMentionCandidate] = [] + var seenDirectoryRelativePaths = directorySeed.seenRelativePaths + fileCandidates.reserveCapacity(min(maxIndexedFiles, 1024)) - func appendCandidate(relativePath: String) { - guard !relativePath.isEmpty, candidates.count < maxIndexedFiles else { return } + func appendDirectoryCandidate(relativePath: String) { + guard !relativePath.isEmpty, + directoryCandidates.count < maxIndexedDirectories, + seenDirectoryRelativePaths.insert(relativePath).inserted else { + return + } + let directoryURL = rootURL + .appendingPathComponent(relativePath, isDirectory: true) + .standardizedFileURL + directoryCandidates.append(Self.directoryCandidate( + relativePath: relativePath, + directoryURL: directoryURL + )) + } + + func appendDirectoryCandidates(containing relativePath: String) { + let components = relativePath.split(separator: "/", omittingEmptySubsequences: true) + guard components.count > 1 else { return } + + var currentPath = "" + for component in components.dropLast() { + let componentName = String(component) + guard !shouldSkipIndexedDirectoryName(componentName) else { return } + currentPath = currentPath.isEmpty ? componentName : "\(currentPath)/\(componentName)" + appendDirectoryCandidate(relativePath: currentPath) + } + } + + func appendFileCandidate(relativePath: String) { + guard !relativePath.isEmpty, fileCandidates.count < maxIndexedFiles else { return } + appendDirectoryCandidates(containing: relativePath) let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardizedFileURL let name = fileURL.lastPathComponent - candidates.append(TextBoxMentionCandidate( - title: "@\(relativePath)", - subtitle: displayPath(fileURL.path), - targetPath: fileURL.path, - systemImageName: "doc", - searchKey: "\(relativePath) \(name)".lowercased(), - priority: min(relativePath.split(separator: "/").count, 20) + fileCandidates.append(Self.fileCandidate( + relativePath: relativePath, + fileURL: fileURL, + fileName: name )) } let stdoutHandle = stdout.fileHandleForReading var buffer = Data() let newline: UInt8 = 10 - while candidates.count < maxIndexedFiles { + while fileCandidates.count < maxIndexedFiles { let chunk = stdoutHandle.readData(ofLength: 64 * 1024) if chunk.isEmpty { break } buffer.append(chunk) while let newlineIndex = buffer.firstIndex(of: newline) { let lineData = Data(buffer[..= maxIndexedFiles { + if fileCandidates.count >= maxIndexedFiles { break } } } - let reachedLimit = candidates.count >= maxIndexedFiles + let reachedLimit = fileCandidates.count >= maxIndexedFiles if reachedLimit, process.isRunning { process.terminate() } else if !buffer.isEmpty, let relativePath = String(data: buffer, encoding: .utf8) { - appendCandidate(relativePath: relativePath) + appendFileCandidate(relativePath: relativePath) } process.waitUntilExit() @@ -1971,7 +2075,186 @@ actor TextBoxMentionIndexStore { return nil } - return candidates.sorted { + return sortedFileSystemCandidates(directoryCandidates + fileCandidates) + } + + private static func scanDirectoryCandidateSeed( + rootURL: URL + ) -> (candidates: [TextBoxMentionCandidate], seenRelativePaths: Set) { + let fileManager = FileManager.default + let rootPath = rootURL.standardizedFileURL.path + let checksGitIgnore = isGitWorkTree(rootURL: rootURL) + var candidates: [TextBoxMentionCandidate] = [] + var seenRelativePaths = Set() + candidates.reserveCapacity(min(maxIndexedDirectories, 256)) + + var directoryQueue = childDirectoryURLs(in: rootURL, fileManager: fileManager) + var queueIndex = 0 + + while queueIndex < directoryQueue.count, candidates.count < maxIndexedDirectories { + let batchEndIndex = min(directoryQueue.count, queueIndex + directorySeedBatchSize) + let directoryBatch = Array(directoryQueue[queueIndex..= maxIndexedDirectories { + break + } + } + + directoryQueue.append(contentsOf: childDirectoryURLs( + in: standardizedURL, + fileManager: fileManager + )) + } + } + + return (candidates, seenRelativePaths) + } + + private static func childDirectoryURLs(in directoryURL: URL, fileManager: FileManager) -> [URL] { + guard let children = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + return [] + } + + return children + .map(\.standardizedFileURL) + .filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true && + !shouldSkipIndexedDirectoryName($0.lastPathComponent) + } + } + + private static func isGitWorkTree(rootURL: URL) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "git", + "-C", rootURL.path, + "rev-parse", + "--is-inside-work-tree" + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return false + } + process.waitUntilExit() + return process.terminationStatus == 0 + } + + private static func gitIgnoredRelativePaths(rootURL: URL, relativePaths: [String]) -> Set { + guard !relativePaths.isEmpty else { return [] } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "git", + "-C", rootURL.path, + "check-ignore", + "--stdin" + ] + + let stdin = Pipe() + let stdout = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return [] + } + + let probePaths = relativePaths + relativePaths.map { "\($0)/" } + let input = probePaths.joined(separator: "\n") + "\n" + if let data = input.data(using: .utf8) { + stdin.fileHandleForWriting.write(data) + } + stdin.fileHandleForWriting.closeFile() + + let output = stdout.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 || process.terminationStatus == 1, + let outputText = String(data: output, encoding: .utf8) else { + return [] + } + + return Set(outputText + .split(separator: "\n", omittingEmptySubsequences: true) + .map(String.init)) + } + + private static func directoryCandidate(relativePath: String, directoryURL: URL) -> TextBoxMentionCandidate { + let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let displayTitle = "@\(normalizedPath)/" + let directoryName = directoryURL.lastPathComponent + return TextBoxMentionCandidate( + title: displayTitle, + subtitle: displayPath(directoryURL.path), + targetPath: directoryURL.path, + systemImageName: "folder", + searchKey: "\(normalizedPath) \(directoryName) folder directory".lowercased(), + priority: directoryPriority(relativePath: normalizedPath) + ) + } + + private static func fileCandidate( + relativePath: String, + fileURL: URL, + fileName: String + ) -> TextBoxMentionCandidate { + TextBoxMentionCandidate( + title: "@\(relativePath)", + subtitle: displayPath(fileURL.path), + targetPath: fileURL.path, + systemImageName: "doc", + searchKey: "\(relativePath) \(fileName)".lowercased(), + priority: filePriority(relativePath: relativePath) + ) + } + + private static func directoryPriority(relativePath: String) -> Int { + let depth = max(relativePath.split(separator: "/").count, 1) + return min((depth * 2) - 2, 40) + } + + private static func filePriority(relativePath: String) -> Int { + let depth = max(relativePath.split(separator: "/").count, 1) + return min((depth * 2) - 1, 41) + } + + private static func sortedFileSystemCandidates( + _ candidates: [TextBoxMentionCandidate] + ) -> [TextBoxMentionCandidate] { + candidates.sorted { if $0.priority != $1.priority { return $0.priority < $1.priority } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index a06255fcba..0b07c0e980 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7150,6 +7150,119 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@README.md](") == true) } + func testTextBoxMentionFileSuggestionsIncludeDirectoriesForEmptyQuery() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-empty-directory-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let sourceDirectory = root.appendingPathComponent("Sources", isDirectory: true) + try fileManager.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: sourceDirectory.appendingPathComponent("Empty", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: root.appendingPathComponent("ZEmpty", isDirectory: true), + withIntermediateDirectories: true + ) + try "nested".write( + to: sourceDirectory.appendingPathComponent("Nested.swift"), + atomically: true, + encoding: .utf8 + ) + try "notes".write( + to: root.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + + let suggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 1), + query: "", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertEqual(suggestions.first?.title, "@Sources/") + XCTAssertEqual(suggestions.first?.systemImageName, "folder") + XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@Sources/](") == true) + XCTAssertTrue(suggestions.contains { $0.title == "@Sources/Empty/" }) + XCTAssertTrue(suggestions.contains { $0.title == "@ZEmpty/" }) + XCTAssertTrue(suggestions.contains { $0.title == "@README.md" }) + } + + func testTextBoxMentionFileSuggestionsFindNestedDirectoriesAndFilesWithFuzzyIndex() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-nested-file-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + let componentsDirectory = root + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("Components", isDirectory: true) + let fixturesDirectory = root.appendingPathComponent("Fixtures", isDirectory: true) + try fileManager.createDirectory(at: componentsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: fixturesDirectory, withIntermediateDirectories: true) + try "struct NestedView {}".write( + to: componentsDirectory.appendingPathComponent("NestedView.swift"), + atomically: true, + encoding: .utf8 + ) + for index in 0..<40 { + try "fixture \(index)".write( + to: fixturesDirectory.appendingPathComponent("Fixture\(index).txt"), + atomically: true, + encoding: .utf8 + ) + } + + let directorySuggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 11), + query: "Components", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertEqual(directorySuggestions.first?.title, "@Sources/Components/") + XCTAssertEqual(directorySuggestions.first?.systemImageName, "folder") + + let nestedFileSuggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 10), + query: "NestedView", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertEqual(nestedFileSuggestions.first?.title, "@Sources/Components/NestedView.swift") + XCTAssertEqual(nestedFileSuggestions.first?.systemImageName, "doc") + + let missingSuggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 14), + query: "MissingNeedle", + trigger: "@" + ), + rootDirectory: root.path + ) + + XCTAssertTrue(missingSuggestions.isEmpty) + } + func testTextBoxMentionFileSuggestionsSkipPackageContents() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent( From 091f963f0009229e3faa598b41f348851f6ffd0f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 19:18:17 -0700 Subject: [PATCH 4/7] Show root mentions while file index warms --- Sources/TextBoxInput.swift | 129 ++++++++++++++++-- .../AppDelegateShortcutRoutingTests.swift | 23 +++- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 6f6170923f..e5a06efc66 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -1740,6 +1740,7 @@ actor TextBoxMentionIndexStore { private static let maxIndexedDirectories = 2000 private static let maxIndexedFiles = 6000 private static let maxIndexedSkills = 800 + private static let rootSuggestionLimit = 200 private static let suggestionLimit = 500 private static let skippedDirectoryNames: Set = [ ".build", @@ -1807,6 +1808,26 @@ actor TextBoxMentionIndexStore { rootDirectory: String ) async -> [TextBoxMentionSuggestion] { let now = Date() + let trimmedQuery = query.query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + if let cachedIndex = cachedFileIndex(rootDirectory: rootDirectory, now: now) { + return cachedIndex.rankedCandidates( + matching: query.query, + limit: Self.suggestionLimit, + shouldCancel: { Task.isCancelled } + ) + .map { $0.suggestion(trigger: query.trigger) } + } + + refreshFileIndexInBackground(rootDirectory: rootDirectory, now: now) + return Self.scanRootFileSystemCandidates(rootURL: URL( + fileURLWithPath: rootDirectory, + isDirectory: true + )) + .prefix(Self.suggestionLimit) + .map { $0.suggestion(trigger: query.trigger) } + } + let index = await fileIndex(rootDirectory: rootDirectory, now: now) if Task.isCancelled { return [] } @@ -1815,7 +1836,7 @@ actor TextBoxMentionIndexStore { limit: Self.suggestionLimit, shouldCancel: { Task.isCancelled } ) - if matches.isEmpty, !query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if matches.isEmpty { let refreshed = await refreshFileIndex(rootDirectory: rootDirectory, now: now) if Task.isCancelled { return [] } matches = refreshed.rankedCandidates( @@ -1828,13 +1849,23 @@ actor TextBoxMentionIndexStore { .map { $0.suggestion(trigger: query.trigger) } } + private func cachedFileIndex( + rootDirectory: String, + now: Date + ) -> TextBoxMentionCandidateIndex? { + guard let cached = fileIndexesByRoot[rootDirectory], + now.timeIntervalSince(cached.createdAt) < Self.fileIndexTTL else { + return nil + } + return cached.index + } + private func fileIndex( rootDirectory: String, now: Date ) async -> TextBoxMentionCandidateIndex { - if let cached = fileIndexesByRoot[rootDirectory], - now.timeIntervalSince(cached.createdAt) < Self.fileIndexTTL { - return cached.index + if let cachedIndex = cachedFileIndex(rootDirectory: rootDirectory, now: now) { + return cachedIndex } return await refreshFileIndex(rootDirectory: rootDirectory, now: now) } @@ -1847,18 +1878,46 @@ actor TextBoxMentionIndexStore { // additional keystrokes await the same scan instead of each spawning a // fresh (and expensive) `rg`/filesystem walk. The detached scan is not // cancelled, so a join here is correct even if the caller's lookup task is. + let scanTask = fileIndexRefreshTask(rootDirectory: rootDirectory) + let index = await scanTask.value + storeFileIndex(rootDirectory: rootDirectory, index: index, createdAt: now) + return index + } + + private func refreshFileIndexInBackground(rootDirectory: String, now: Date) { + guard cachedFileIndex(rootDirectory: rootDirectory, now: now) == nil else { return } + let scanTask = fileIndexRefreshTask(rootDirectory: rootDirectory) + Task { [rootDirectory, now, scanTask] in + let index = await scanTask.value + await self.storeFileIndex(rootDirectory: rootDirectory, index: index, createdAt: now) + } + } + + private func fileIndexRefreshTask( + rootDirectory: String + ) -> Task { if let inFlight = fileIndexRefreshTasks[rootDirectory] { - return await inFlight.value + return inFlight } + let rootURL = URL(fileURLWithPath: rootDirectory, isDirectory: true) let scanTask = Task.detached(priority: .utility) { TextBoxMentionCandidateIndex(candidates: Self.scanFiles(rootURL: rootURL)) } fileIndexRefreshTasks[rootDirectory] = scanTask - let index = await scanTask.value + return scanTask + } + + private func storeFileIndex( + rootDirectory: String, + index: TextBoxMentionCandidateIndex, + createdAt: Date + ) { + if let cached = fileIndexesByRoot[rootDirectory], cached.createdAt > createdAt { + return + } fileIndexRefreshTasks[rootDirectory] = nil - fileIndexesByRoot[rootDirectory] = CachedIndex(index: index, createdAt: now) - return index + fileIndexesByRoot[rootDirectory] = CachedIndex(index: index, createdAt: createdAt) } private func skillIndex(rootDirectory: String?) -> TextBoxMentionCandidateIndex { @@ -1964,6 +2023,60 @@ actor TextBoxMentionIndexStore { return sortedFileSystemCandidates(directoryCandidates + fileCandidates) } + private static func scanRootFileSystemCandidates(rootURL: URL) -> [TextBoxMentionCandidate] { + let fileManager = FileManager.default + guard let children = try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + let rootPath = rootURL.standardizedFileURL.path + let candidateURLs = children + .map(\.standardizedFileURL) + .filter { url in + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + return !shouldSkipIndexedDirectoryName(url.lastPathComponent) + } + return values?.isRegularFile == true + } + let relativePaths = candidateURLs.map { + Self.relativePath(for: $0.path, rootPath: rootPath) + } + let ignoredRelativePaths = isGitWorkTree(rootURL: rootURL) + ? gitIgnoredRelativePaths(rootURL: rootURL, relativePaths: relativePaths) + : [] + + var candidates: [TextBoxMentionCandidate] = [] + candidates.reserveCapacity(candidateURLs.count) + for url in candidateURLs { + let relativePath = Self.relativePath(for: url.path, rootPath: rootPath) + guard !relativePath.isEmpty, + !ignoredRelativePaths.contains(relativePath), + !ignoredRelativePaths.contains("\(relativePath)/") else { + continue + } + + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + candidates.append(Self.directoryCandidate( + relativePath: relativePath, + directoryURL: url + )) + } else if values?.isRegularFile == true { + candidates.append(Self.fileCandidate( + relativePath: relativePath, + fileURL: url, + fileName: url.lastPathComponent + )) + } + } + return Array(sortedFileSystemCandidates(candidates).prefix(rootSuggestionLimit)) + } + private static func scanFilesWithRipgrep(rootURL: URL) -> [TextBoxMentionCandidate]? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 0b07c0e980..c670646ad5 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7192,9 +7192,30 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(suggestions.first?.title, "@Sources/") XCTAssertEqual(suggestions.first?.systemImageName, "folder") XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@Sources/](") == true) - XCTAssertTrue(suggestions.contains { $0.title == "@Sources/Empty/" }) XCTAssertTrue(suggestions.contains { $0.title == "@ZEmpty/" }) XCTAssertTrue(suggestions.contains { $0.title == "@README.md" }) + + let nestedFileSuggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 7), + query: "Nested", + trigger: "@" + ), + rootDirectory: root.path + ) + XCTAssertEqual(nestedFileSuggestions.first?.title, "@Sources/Nested.swift") + + let warmedSuggestions = await TextBoxMentionIndexStore.shared.suggestions( + for: TextBoxMentionQuery( + kind: .file, + range: NSRange(location: 0, length: 1), + query: "", + trigger: "@" + ), + rootDirectory: root.path + ) + XCTAssertTrue(warmedSuggestions.contains { $0.title == "@Sources/Empty/" }) } func testTextBoxMentionFileSuggestionsFindNestedDirectoriesAndFilesWithFuzzyIndex() async throws { From 8db5b31da6920e90ff31017a840f9956ca6eeb34 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 19:30:43 -0700 Subject: [PATCH 5/7] Open textbox mention popover while loading --- Sources/TextBoxInput.swift | 124 ++++++++++++++---- .../AppDelegateShortcutRoutingTests.swift | 53 ++++++++ 2 files changed, 152 insertions(+), 25 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index e5a06efc66..9b05753594 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -1733,9 +1733,11 @@ actor TextBoxMentionIndexStore { private struct CachedIndex { let index: TextBoxMentionCandidateIndex let createdAt: Date + let lastAccessedAt: Date } private static let fileIndexTTL: TimeInterval = 30 + private static let maxCachedFileIndexes = 8 private static let directorySeedBatchSize = 128 private static let maxIndexedDirectories = 2000 private static let maxIndexedFiles = 6000 @@ -1853,10 +1855,16 @@ actor TextBoxMentionIndexStore { rootDirectory: String, now: Date ) -> TextBoxMentionCandidateIndex? { + pruneFileIndexCache(now: now) guard let cached = fileIndexesByRoot[rootDirectory], now.timeIntervalSince(cached.createdAt) < Self.fileIndexTTL else { return nil } + fileIndexesByRoot[rootDirectory] = CachedIndex( + index: cached.index, + createdAt: cached.createdAt, + lastAccessedAt: now + ) return cached.index } @@ -1917,7 +1925,36 @@ actor TextBoxMentionIndexStore { return } fileIndexRefreshTasks[rootDirectory] = nil - fileIndexesByRoot[rootDirectory] = CachedIndex(index: index, createdAt: createdAt) + let storedAt = Date() + fileIndexesByRoot[rootDirectory] = CachedIndex( + index: index, + createdAt: createdAt, + lastAccessedAt: storedAt + ) + pruneFileIndexCache(now: storedAt) + } + + private func pruneFileIndexCache(now: Date) { + let expiredRoots = fileIndexesByRoot.compactMap { rootDirectory, cached in + now.timeIntervalSince(cached.createdAt) >= Self.fileIndexTTL ? rootDirectory : nil + } + for rootDirectory in expiredRoots { + fileIndexesByRoot[rootDirectory] = nil + } + + guard fileIndexesByRoot.count > Self.maxCachedFileIndexes else { return } + let rootsToRemove = fileIndexesByRoot + .sorted { lhs, rhs in + if lhs.value.lastAccessedAt != rhs.value.lastAccessedAt { + return lhs.value.lastAccessedAt < rhs.value.lastAccessedAt + } + return lhs.key < rhs.key + } + .prefix(fileIndexesByRoot.count - Self.maxCachedFileIndexes) + .map(\.key) + for rootDirectory in rootsToRemove { + fileIndexesByRoot[rootDirectory] = nil + } } private func skillIndex(rootDirectory: String?) -> TextBoxMentionCandidateIndex { @@ -2550,6 +2587,7 @@ actor TextBoxMentionIndexStore { private final class TextBoxMentionCompletionController { private(set) var suggestions: [TextBoxMentionSuggestion] = [] private(set) var selectionIndex: Int = 0 + private(set) var isLoadingSuggestions = false @ObservationIgnored private(set) var activeQuery: TextBoxMentionQuery? @@ -2572,6 +2610,10 @@ private final class TextBoxMentionCompletionController { activeQuery != nil } + var shouldShowPopover: Bool { + isActive && (hasSuggestions || isLoadingSuggestions) + } + var hasCurrentSuggestions: Bool { hasSuggestions && suggestionsQuery == activeQuery && @@ -2602,6 +2644,7 @@ private final class TextBoxMentionCompletionController { activeQuery = query 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 @@ -2628,6 +2671,7 @@ private final class TextBoxMentionCompletionController { self.suggestions = suggestions self.suggestionsQuery = query self.suggestionsRootDirectory = rootDirectory + self.isLoadingSuggestions = false self.selectionIndex = suggestions.isEmpty ? 0 : min(self.selectionIndex, suggestions.count - 1) self.onStateChanged?() } @@ -2647,6 +2691,7 @@ private final class TextBoxMentionCompletionController { suggestions = [] suggestionsQuery = nil suggestionsRootDirectory = nil + isLoadingSuggestions = false selectionIndex = 0 lookupTask?.cancel() lookupTask = nil @@ -2660,7 +2705,8 @@ private final class TextBoxMentionCompletionController { #if DEBUG func debugSetState( query: TextBoxMentionQuery?, - suggestions debugSuggestions: [TextBoxMentionSuggestion] + suggestions debugSuggestions: [TextBoxMentionSuggestion], + isLoading: Bool = false ) { lookupTask?.cancel() lookupTask = nil @@ -2669,6 +2715,7 @@ private final class TextBoxMentionCompletionController { suggestions = debugSuggestions suggestionsQuery = query suggestionsRootDirectory = nil + isLoadingSuggestions = isLoading selectionIndex = suggestions.isEmpty ? 0 : min(selectionIndex, suggestions.count - 1) onStateChanged?() } @@ -2680,6 +2727,10 @@ private final class TextBoxMentionCompletionController { var debugHasCurrentSuggestions: Bool { hasCurrentSuggestions } + + var debugShouldShowPopover: Bool { + shouldShowPopover + } #endif } @@ -2687,29 +2738,40 @@ private struct TextBoxMentionCompletionPopoverView: View { let suggestions: [TextBoxMentionSuggestion] let selectionIndex: Int let searchTerm: String + let isLoading: Bool let onSelect: (TextBoxMentionSuggestion) -> Void var body: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: true) { LazyVStack(alignment: .leading, spacing: 1) { - ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, suggestion in - Button { - onSelect(suggestion) - } label: { - Text(Self.highlightedTitle(suggestion.title, query: searchTerm)) - .font(.system(size: 12, weight: .semibold)) - .lineLimit(1) - .truncationMode(.middle) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(index == selectionIndex ? Color.accentColor.opacity(0.24) : Color.clear) - } + if suggestions.isEmpty, isLoading { + HStack { + Spacer(minLength: 0) + ProgressView() + .controlSize(.small) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 28, alignment: .center) + } else { + ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, suggestion in + Button { + onSelect(suggestion) + } label: { + Text(Self.highlightedTitle(suggestion.title, query: searchTerm)) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(index == selectionIndex ? Color.accentColor.opacity(0.24) : Color.clear) + } + } + .buttonStyle(.plain) + .id(index) } - .buttonStyle(.plain) - .id(index) } } .padding(4) @@ -5478,7 +5540,7 @@ final class TextBoxInputTextView: NSTextView { return acceptMentionCompletion() case kVK_Escape: if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } - guard mentionCompletionController.hasSuggestions else { return false } + guard mentionCompletionController.shouldShowPopover else { return false } dismissMentionCompletions() return true default: @@ -5508,7 +5570,7 @@ final class TextBoxInputTextView: NSTextView { return acceptMentionCompletion() case #selector(NSResponder.cancelOperation(_:)): if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } - guard mentionCompletionController.hasSuggestions else { return false } + guard mentionCompletionController.shouldShowPopover else { return false } dismissMentionCompletions() return true default: @@ -5586,7 +5648,7 @@ final class TextBoxInputTextView: NSTextView { } private func syncMentionCompletionPopover() { - guard mentionCompletionController.hasSuggestions else { + guard mentionCompletionController.shouldShowPopover else { dismissMentionCompletionPopoverOnly() return } @@ -5600,7 +5662,9 @@ final class TextBoxInputTextView: NSTextView { } updateMentionCompletionWindowObservers(for: parentWindow) - let rowCount = mentionCompletionController.suggestions.count + let showsLoadingRow = mentionCompletionController.suggestions.isEmpty && + mentionCompletionController.isLoadingSuggestions + let rowCount = showsLoadingRow ? 1 : mentionCompletionController.suggestions.count let maxVisibleRows = 12 let visibleRows = min(rowCount, maxVisibleRows) let rowHeight: CGFloat = 25 @@ -5718,7 +5782,7 @@ final class TextBoxInputTextView: NSTextView { } private func repositionMentionCompletionPanelIfNeeded() { - guard mentionCompletionController.hasSuggestions, + guard mentionCompletionController.shouldShowPopover, let panel = mentionCompletionPanel, panel.isVisible, NSApp.isActive, @@ -5790,6 +5854,7 @@ final class TextBoxInputTextView: NSTextView { suggestions: mentionCompletionController.suggestions, selectionIndex: mentionCompletionController.selectionIndex, searchTerm: mentionCompletionController.activeQuery?.query ?? "", + isLoading: mentionCompletionController.isLoadingSuggestions, onSelect: { [weak self] suggestion in self?.window?.makeFirstResponder(self) self?.acceptMentionCompletion(suggestion) @@ -5903,9 +5968,14 @@ final class TextBoxInputTextView: NSTextView { #if DEBUG func debugSetMentionCompletionState( query: TextBoxMentionQuery?, - suggestions: [TextBoxMentionSuggestion] + suggestions: [TextBoxMentionSuggestion], + isLoading: Bool = false ) { - mentionCompletionController.debugSetState(query: query, suggestions: suggestions) + mentionCompletionController.debugSetState( + query: query, + suggestions: suggestions, + isLoading: isLoading + ) } func debugMentionSuggestionCount() -> Int { @@ -5916,6 +5986,10 @@ final class TextBoxInputTextView: NSTextView { mentionCompletionController.debugHasCurrentSuggestions } + func debugMentionCompletionsShouldShowPopover() -> Bool { + mentionCompletionController.debugShouldShowPopover + } + func debugAcceptMentionCompletion() -> Bool { acceptMentionCompletion() } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index c670646ad5..ae81fea7de 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7535,6 +7535,32 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(textView.debugMentionSuggestionCount(), 0) } + func testTextBoxMentionRefreshOpensPopoverImmediatelyForBareFileTrigger() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-textbox-loading-file-mentions-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: root) } + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try "notes".write( + to: root.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.completionRootDirectory = root.path + textView.string = "@" + textView.setSelectedRange(NSRange(location: 1, length: 0)) + + textView.refreshMentionCompletions() + + XCTAssertTrue(textView.debugMentionCompletionsShouldShowPopover()) + XCTAssertEqual(textView.debugMentionSuggestionCount(), 0) + } + func testTextBoxMentionEscapeFallsThroughWhenQueryHasNoSuggestions() { let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) textView.string = "@missing" @@ -7560,6 +7586,33 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(escapeCount, 1) } + func testTextBoxMentionEscapeDismissesLoadingPopover() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "@" + textView.setSelectedRange(NSRange(location: 1, length: 0)) + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery(kind: .file, range: NSRange(location: 0, length: 1), query: ""), + suggestions: [], + isLoading: true + ) + var escapeCount = 0 + textView.onEscape = { escapeCount += 1 } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: UInt16(kVK_Escape), + windowNumber: 0 + ) else { + XCTFail("Failed to construct Escape event") + return + } + + textView.keyDown(with: escapeEvent) + XCTAssertEqual(escapeCount, 0) + XCTAssertFalse(textView.debugMentionCompletionsShouldShowPopover()) + } + func testTextBoxMentionBareSkillTriggerReturnSubmitsInsteadOfAcceptingFirstSuggestion() { let scenarios: [(text: String, range: NSRange, trigger: Character, insertionText: String)] = [ ("cd /", NSRange(location: 3, length: 1), "/", "[/sample-skill](/tmp/sample-skill/SKILL.md)"), From d188c727f269b5ee9838c3b1da7acda3403c9949 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 19:33:00 -0700 Subject: [PATCH 6/7] Address textbox mention review feedback --- Sources/TextBoxInput.swift | 15 +++--- .../AppDelegateShortcutRoutingTests.swift | 54 +++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 9b05753594..f0df7c31fd 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -5530,12 +5530,11 @@ final class TextBoxInputTextView: NSTextView { case kVK_Return, kVK_ANSI_KeypadEnter: guard !flags.contains(.shift) else { return false } if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } - if shouldBypassMentionCompletionKeyboardAcceptance() { return false } + if shouldBypassMentionCompletionReturnAcceptance() { return false } if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case kVK_Tab: if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } - if shouldBypassMentionCompletionKeyboardAcceptance() { return false } if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case kVK_Escape: @@ -5562,10 +5561,13 @@ final class TextBoxInputTextView: NSTextView { guard mentionCompletionController.hasCurrentSuggestions else { return false } mentionCompletionController.moveSelection(delta: 1) return true - case #selector(NSResponder.insertNewline(_:)), - #selector(NSResponder.insertTab(_:)): + case #selector(NSResponder.insertNewline(_:)): + if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } + if shouldBypassMentionCompletionReturnAcceptance() { return false } + if mentionCompletionController.hasStaleSuggestions { return true } + return acceptMentionCompletion() + case #selector(NSResponder.insertTab(_:)): if shouldBypassHiddenMentionCompletionKeyboardInteraction() { return false } - if shouldBypassMentionCompletionKeyboardAcceptance() { return false } if mentionCompletionController.hasStaleSuggestions { return true } return acceptMentionCompletion() case #selector(NSResponder.cancelOperation(_:)): @@ -5590,7 +5592,7 @@ final class TextBoxInputTextView: NSTextView { return false } - private func shouldBypassMentionCompletionKeyboardAcceptance() -> Bool { + private func shouldBypassMentionCompletionReturnAcceptance() -> Bool { guard let query = mentionCompletionController.activeQuery, query.kind == .skill, query.query.isEmpty else { @@ -5715,6 +5717,7 @@ final class TextBoxInputTextView: NSTextView { backing: .buffered, defer: false ) + panel.identifier = NSUserInterfaceItemIdentifier("cmux.textbox.mentionCompletionPanel") panel.isFloatingPanel = true panel.hidesOnDeactivate = true panel.becomesKeyOnlyIfNeeded = true diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index ae81fea7de..71d4eb2014 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7189,9 +7189,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { rootDirectory: root.path ) - XCTAssertEqual(suggestions.first?.title, "@Sources/") - XCTAssertEqual(suggestions.first?.systemImageName, "folder") - XCTAssertTrue(suggestions.first?.insertionText.hasPrefix("[@Sources/](") == true) + let sourcesDirectory = suggestions.first { $0.title == "@Sources/" } + XCTAssertNotNil(sourcesDirectory) + XCTAssertEqual(sourcesDirectory?.systemImageName, "folder") + XCTAssertTrue(sourcesDirectory?.insertionText.hasPrefix("[@Sources/](") == true) XCTAssertTrue(suggestions.contains { $0.title == "@ZEmpty/" }) XCTAssertTrue(suggestions.contains { $0.title == "@README.md" }) @@ -7651,6 +7652,53 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } + func testTextBoxMentionBareSkillTriggerTabAcceptsFirstSuggestion() { + let scenarios: [(text: String, range: NSRange, trigger: Character, insertionText: String, expected: String)] = [ + ( + "cd /", + NSRange(location: 3, length: 1), + "/", + "[/sample-skill](/tmp/sample-skill/SKILL.md)", + "cd [/sample-skill](/tmp/sample-skill/SKILL.md) " + ), + ( + "echo $", + NSRange(location: 5, length: 1), + "$", + "$sample-skill", + "echo $sample-skill " + ) + ] + + for scenario in scenarios { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = scenario.text + textView.setSelectedRange(NSRange(location: (scenario.text as NSString).length, length: 0)) + textView.debugSetMentionCompletionState( + query: TextBoxMentionQuery( + kind: .skill, + range: scenario.range, + query: "", + trigger: scenario.trigger + ), + suggestions: [ + TextBoxMentionSuggestion( + id: "\(scenario.trigger):/tmp/sample-skill/SKILL.md", + title: "\(scenario.trigger)sample-skill", + subtitle: "/tmp/sample-skill/SKILL.md", + insertionText: scenario.insertionText, + systemImageName: "sparkle.magnifyingglass" + ) + ] + ) + + textView.doCommand(by: #selector(NSResponder.insertTab(_:))) + + XCTAssertEqual(textView.string, scenario.expected) + XCTAssertEqual(textView.debugMentionSuggestionCount(), 0) + } + } + func testTextBoxSubmitUsesPastePayloadAndSeparateReturn() throws { XCTAssertEqual(TextBoxSubmit.submittedPasteText(for: "hello"), "hello") XCTAssertEqual(TextBoxSubmit.submittedPasteText(for: "hello\nworld"), "hello\nworld") From 87714679b15d0ce83283f9006f0652222d2812cd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 19:50:13 -0700 Subject: [PATCH 7/7] Ignore textbox completion panel in close shortcut lint --- scripts/lint_auxiliary_window_close_shortcuts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/lint_auxiliary_window_close_shortcuts.py b/scripts/lint_auxiliary_window_close_shortcuts.py index 8e365ce91f..713a874c0a 100755 --- a/scripts/lint_auxiliary_window_close_shortcuts.py +++ b/scripts/lint_auxiliary_window_close_shortcuts.py @@ -20,6 +20,8 @@ # Hidden WebKit preload host; it is not user closable and must not own Cmd+W. "cmux.browserBackgroundPreload", "cmux.bootstrap", + # Cursor-anchored textbox completion popup; it never becomes key/main. + "cmux.textbox.mentionCompletionPanel", } IDENTIFIER_ASSIGNMENT_RE = re.compile(