Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Sources/TextBoxMentionCandidateIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ struct TextBoxMentionCandidateIndex: Sendable {
title: candidate.title,
searchableTexts: [
candidate.title,
candidate.subtitle,
candidate.searchKey
]
)
Expand Down
13 changes: 8 additions & 5 deletions Sources/TextBoxMentionCompletionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@ final class TextBoxMentionCompletionController {
activeRootDirectory = rootDirectory
selectionIndex = 0
isLoadingSuggestions = true
// Editing within the same trigger keeps the current rows on screen until
// the async lookup returns, avoiding a per-keystroke popover flicker.
// Switching triggers is a different completion kind, so drop stale rows
// immediately rather than showing them under the wrong trigger.
if previousActiveQuery?.trigger != query.trigger || previousRootDirectory != rootDirectory {
// Once the user has typed a non-empty query, stale bare-trigger rows
// read as wrong fuzzy results. Show the loading row until the exact
// query finishes instead.
let queryChangedToNonEmpty = previousActiveQuery?.query != query.query &&
!query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 queryChangedToNonEmpty fires on every non-empty keystroke, not just the bare-trigger → first-character transition

The condition checks previousQuery != query.query && !query.query.isEmpty, which is true on every character typed or deleted that leaves a non-empty query — for example, backspacing /iterate-pr to /iterate-p sets it to true and clears the stale rows. The name and the PR description ("clear stale bare-trigger rows when the same trigger changes to a non-empty query") imply the intent is the empty→non-empty edge only, but the implementation acts on any→non-empty. The old test explicitly asserted that editing within the same trigger kept rows visible; that assertion was removed rather than updated, so there is no coverage of the typing-within-a-query path. Every keystroke beyond the first now shows a loading spinner instead of the previous results, which is the flicker the original design was trying to avoid.

Suggested change
let queryChangedToNonEmpty = previousActiveQuery?.query != query.query &&
!query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let previousQueryWasEmpty = (previousActiveQuery?.query ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let queryChangedToNonEmpty = previousQueryWasEmpty &&
!query.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b699f7 by making the clear condition require the previous query to be empty. Added a regression test that keeps rows visible when a same-trigger query stays nonempty.

— Claude Code

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if previousActiveQuery?.trigger != query.trigger ||
previousRootDirectory != rootDirectory ||
queryChangedToNonEmpty {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
suggestions = []
suggestionsQuery = nil
suggestionsRootDirectory = nil
Expand Down
11 changes: 10 additions & 1 deletion Sources/TextBoxMentionIndexStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ actor TextBoxMentionIndexStore {
subtitle: Self.displayPath(path),
targetPath: path,
systemImageName: "sparkle.magnifyingglass",
searchKey: "\(skillName) \(path)".lowercased(),
searchKey: Self.skillSearchKey(skillName: skillName, skillURL: skillURL, rootURL: root),
priority: rootIndex
))
if candidates.count >= Self.maxIndexedSkills {
Expand Down Expand Up @@ -897,6 +897,15 @@ actor TextBoxMentionIndexStore {
return skillURL.deletingLastPathComponent().lastPathComponent
}

private static func skillSearchKey(skillName: String, skillURL: URL, rootURL: URL) -> String {
let skillDirectory = skillURL.deletingLastPathComponent().standardizedFileURL
let relativeSkillPath = relativePath(
for: skillDirectory.path,
rootPath: rootURL.standardizedFileURL.path
)
return "\(skillName) \(relativeSkillPath)".lowercased()
}

private static func normalizedDirectory(_ path: String?) -> String? {
guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty else {
Expand Down
94 changes: 66 additions & 28 deletions cmuxTests/TextBoxMentionCompletionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -928,46 +928,84 @@ struct TextBoxMentionCompletionTests {
}

@Test
func testTextBoxMentionRefreshKeepsRowsOnSameTriggerEditButClearsOnTriggerChange() {
func testTextBoxMentionSkillSuggestionsPreferExactNameOverPathOnlyFuzzyMatches() async throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory.appendingPathComponent(
"cmux-textbox-skill-fuzzy-filter-\(UUID().uuidString)",
isDirectory: true
)
defer { try? fileManager.removeItem(at: root) }

let skillsDirectory = root.appendingPathComponent("skills", isDirectory: true)
let skillNames = [
"agent-browser",
"agent-cli-integration",
"algorithmic-complexity-audit",
"auto-issue",
"cleanup-dev-builds",
"close-issues",
"pi-agent-rust",
"xcodebuildmcp-cli",
"iterate-pr"
] + (0..<40).map { String(format: "zzz-distractor-%02d", $0) }
for skillName in skillNames {
let skillDirectory = skillsDirectory.appendingPathComponent(skillName, isDirectory: true)
try fileManager.createDirectory(at: skillDirectory, withIntermediateDirectories: true)
try "name: \(skillName)\n".write(
to: skillDirectory.appendingPathComponent("SKILL.md"),
atomically: true,
encoding: .utf8
)
}

for trigger in ["/", "$"] as [Character] {
let suggestions = await TextBoxMentionIndexStore.shared.suggestions(
for: TextBoxMentionQuery(
kind: .skill,
range: NSRange(location: 0, length: 11),
query: "iterate-pr",
trigger: trigger
),
rootDirectory: root.path
)

#expect(suggestions.first?.title == "\(trigger)iterate-pr")
#expect(!suggestions.contains { $0.title == "\(trigger)pi-agent-rust" })
#expect(!suggestions.contains { $0.title == "\(trigger)agent-browser" })
}
}

@Test
func testTextBoxMentionRefreshClearsRowsWhenSameTriggerQueryBecomesNonEmpty() {
let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30))
textView.string = "@a"
textView.setSelectedRange(NSRange(location: 2, length: 0))
textView.string = "$"
textView.setSelectedRange(NSRange(location: 1, length: 0))
let staleSuggestion = TextBoxMentionSuggestion(
id: "alpha",
title: "@alpha.txt",
subtitle: "alpha.txt",
insertionText: "[@alpha.txt](/tmp/alpha.txt)",
systemImageName: "doc"
id: "$:/tmp/agent-browser/SKILL.md",
title: "$agent-browser",
subtitle: "/tmp/agent-browser/SKILL.md",
insertionText: "$agent-browser",
systemImageName: "sparkle.magnifyingglass"
)

textView.debugSetMentionCompletionState(
query: TextBoxMentionQuery(kind: .file, range: NSRange(location: 0, length: 2), query: "a"),
query: TextBoxMentionQuery(
kind: .skill,
range: NSRange(location: 0, length: 1),
query: "",
trigger: "$"
),
suggestions: [staleSuggestion]
)
#expect(textView.debugMentionSuggestionCount() == 1)

// Editing within the same trigger keeps the previous rows visible until
// the async lookup returns, avoiding a per-keystroke popover flicker.
textView.string = "@z"
textView.setSelectedRange(NSRange(location: 2, length: 0))
textView.string = "$iterate-pr"
textView.setSelectedRange(NSRange(location: 11, length: 0))
textView.refreshMentionCompletions()
#expect(textView.debugMentionSuggestionCount() == 1)
#expect(!(textView.debugMentionSuggestionsAreCurrent()))
#expect(textView.debugMentionSuggestionCount() == 0)
#expect(textView.debugMentionCompletionsShouldShowPopover())
#expect(!(textView.debugAcceptMentionCompletion()))
#expect(!(textView.debugAcceptMentionCompletion(suggestion: staleSuggestion)))
#expect(textView.string == "@z")
var submitCount = 0
textView.onSubmit = { submitCount += 1 }
textView.doCommand(by: #selector(NSResponder.insertNewline(_:)))
#expect(submitCount == 1)
#expect(textView.string == "@z")

// Switching the trigger is a different completion kind, so stale rows are
// dropped immediately rather than shown under the wrong trigger.
textView.string = "/z"
textView.setSelectedRange(NSRange(location: 2, length: 0))
textView.refreshMentionCompletions()
#expect(textView.debugMentionSuggestionCount() == 0)
}

@Test
Expand Down
Loading