diff --git a/py/services/tag_fts_index.py b/py/services/tag_fts_index.py index d6e95a07..8223264a 100644 --- a/py/services/tag_fts_index.py +++ b/py/services/tag_fts_index.py @@ -449,6 +449,11 @@ class TagFTSIndex: Supports alias search: if the query matches an alias rather than the tag_name, the result will include a "matched_alias" field. + Ranking is based on a combination of: + 1. FTS5 bm25 relevance score (how well the text matches) + 2. Post count (popularity) + 3. Exact prefix match boost (tag_name starts with query) + Args: query: The search query string. categories: Optional list of category IDs to filter by. @@ -457,7 +462,7 @@ class TagFTSIndex: Returns: List of dictionaries with tag_name, category, post_count, - and optionally matched_alias. + rank_score, and optionally matched_alias. """ # Ensure index is ready (lazy initialization) if not self.ensure_ready(): @@ -473,35 +478,67 @@ class TagFTSIndex: if not fts_query: return [] + query_lower = query.lower().strip() + try: with self._lock: conn = self._connect(readonly=True) try: - # Build the SQL query - now also fetch aliases for matched_alias detection - # Use subquery for category filter to ensure FTS is evaluated first + # Build the SQL query with bm25 ranking + # FTS5 bm25() returns negative scores, lower is better + # We use -bm25() to get higher=better scores + # Weights: -100.0 for exact matches, 1.0 for others + # Add LOG10(post_count) weighting to boost popular tags + # Use CASE to boost tag_name prefix matches above alias matches if categories: placeholders = ",".join("?" * len(categories)) sql = f""" - SELECT t.tag_name, t.category, t.post_count, t.aliases - FROM tags t - WHERE t.rowid IN ( - SELECT rowid FROM tag_fts WHERE searchable_text MATCH ? - ) + SELECT t.tag_name, t.category, t.post_count, t.aliases, + CASE + WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1 + ELSE 0 + END AS is_tag_name_match, + bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score + FROM tag_fts + JOIN tags t ON tag_fts.rowid = t.rowid + WHERE tag_fts.searchable_text MATCH ? AND t.category IN ({placeholders}) - ORDER BY t.post_count DESC + ORDER BY is_tag_name_match DESC, rank_score DESC LIMIT ? OFFSET ? """ - params = [fts_query] + categories + [limit, offset] + # Escape special LIKE characters and add wildcard + query_escaped = ( + query_lower.lstrip("/") + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + params = ( + [query_escaped + "%", fts_query] + + categories + + [limit, offset] + ) else: sql = """ - SELECT t.tag_name, t.category, t.post_count, t.aliases - FROM tag_fts f - JOIN tags t ON f.rowid = t.rowid - WHERE f.searchable_text MATCH ? - ORDER BY t.post_count DESC + SELECT t.tag_name, t.category, t.post_count, t.aliases, + CASE + WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1 + ELSE 0 + END AS is_tag_name_match, + bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score + FROM tag_fts + JOIN tags t ON tag_fts.rowid = t.rowid + WHERE tag_fts.searchable_text MATCH ? + ORDER BY is_tag_name_match DESC, rank_score DESC LIMIT ? OFFSET ? """ - params = [fts_query, limit, offset] + query_escaped = ( + query_lower.lstrip("/") + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + params = [query_escaped + "%", fts_query, limit, offset] cursor = conn.execute(sql, params) results = [] @@ -510,8 +547,17 @@ class TagFTSIndex: "tag_name": row[0], "category": row[1], "post_count": row[2], + "is_tag_name_match": row[4] == 1, + "rank_score": row[5], } + # Set is_exact_prefix based on tag_name match + tag_name = row[0] + if tag_name.lower().startswith(query_lower.lstrip("/")): + result["is_exact_prefix"] = True + else: + result["is_exact_prefix"] = result["is_tag_name_match"] + # Check if search matched an alias rather than the tag_name matched_alias = self._find_matched_alias(query, row[0], row[3]) if matched_alias: diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index a64227fb..0f501605 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -156,4 +156,115 @@ describe('AutoComplete widget interactions', () => { expect(highlighted).toContain('detail'); expect(highlighted).not.toMatch(/beta<\/span>/i); }); + + it('handles arrow key navigation with virtual scrolling', async () => { + vi.useFakeTimers(); + + const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: mockItems }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('model'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { + debounceDelay: 0, + showPreview: false, + enableVirtualScroll: true, + itemHeight: 40, + visibleItems: 15, + pageSize: 20, + }); + + input.value = 'model'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(autoComplete.items.length).toBeGreaterThan(0); + expect(autoComplete.selectedIndex).toBe(0); + + const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected'); + expect(initialSelectedEl).toBeDefined(); + + const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); + input.dispatchEvent(arrowDownEvent); + + expect(autoComplete.selectedIndex).toBe(1); + + const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected'); + expect(secondSelectedEl).toBeDefined(); + expect(secondSelectedEl?.dataset.index).toBe('1'); + + const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }); + input.dispatchEvent(arrowUpEvent); + + expect(autoComplete.selectedIndex).toBe(0); + + const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected'); + expect(firstSelectedElAgain).toBeDefined(); + expect(firstSelectedElAgain?.dataset.index).toBe('0'); + }); + + it('maintains selection when scrolling to invisible items', async () => { + vi.useFakeTimers(); + + const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ success: true, relative_paths: mockItems }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('item'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.style.width = '400px'; + input.style.height = '200px'; + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'loras', { + debounceDelay: 0, + showPreview: false, + enableVirtualScroll: true, + itemHeight: 40, + visibleItems: 15, + pageSize: 20, + }); + + input.value = 'item'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(autoComplete.items.length).toBeGreaterThan(0); + + autoComplete.selectedIndex = 14; + + const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0; + + const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); + input.dispatchEvent(arrowDownEvent); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(autoComplete.selectedIndex).toBe(15); + + const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected'); + expect(selectedEl).toBeDefined(); + expect(selectedEl?.dataset.index).toBe('15'); + + const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0; + expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore); + }); }); diff --git a/tests/test_tag_fts_index.py b/tests/test_tag_fts_index.py index 73789352..cb5aebb3 100644 --- a/tests/test_tag_fts_index.py +++ b/tests/test_tag_fts_index.py @@ -31,10 +31,27 @@ def temp_db_path(): @pytest.fixture def temp_csv_path(): """Create a temporary CSV file with test data.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, encoding="utf-8" + ) as f: # Write test data in the same format as danbooru_e621_merged.csv # Format: tag_name,category,post_count,aliases + # Include multiple tags starting with "1" to test popularity-based ranking f.write('1girl,0,6008644,"1girls,sole_female"\n') + f.write('1boy,0,1405457,"1boys,sole_male"\n') + f.write('1:1,14,377032,""\n') + f.write('16:9,14,152866,""\n') + f.write('1other,0,70962,""\n') + f.write('16:10,14,14739,""\n') + f.write('1990s_(style),0,9369,""\n') + f.write('1_eye,0,7179,""\n') + f.write('1:2,14,5865,""\n') + f.write('1980s_(style),0,5665,""\n') + f.write('1koma,0,4384,""\n') + f.write('1_horn,0,2122,""\n') + f.write('101_dalmatian_street,3,1933,""\n') + f.write('1upgobbo,3,1731,""\n') + f.write('14:9,14,1038,""\n') f.write('highres,5,5256195,"high_res,high_resolution,hires"\n') f.write('solo,0,5000954,"alone,female_solo,single"\n') f.write('hatsune_miku,4,500000,"miku"\n') @@ -86,7 +103,7 @@ class TestTagFTSIndexBuild: fts.build_index() assert fts.is_ready() is True - assert fts.get_indexed_count() == 10 + assert fts.get_indexed_count() == 24 def test_build_index_nonexistent_csv(self, temp_db_path): """Test that build_index handles missing CSV gracefully.""" @@ -187,6 +204,76 @@ class TestTagFTSIndexSearch: results = populated_fts.search("girl", limit=1) assert len(results) <= 1 + def test_search_tag_name_prefix_match_priority(self, populated_fts): + """Test that tag_name prefix matches rank higher than alias matches.""" + results = populated_fts.search("1", limit=20) + + assert len(results) > 0, "Should return results for '1'" + + # Find first alias match (if any) + first_alias_idx = None + for i, result in enumerate(results): + if result.get("matched_alias"): + first_alias_idx = i + break + + # All tag_name prefix matches should come before alias matches + if first_alias_idx is not None: + for i in range(first_alias_idx): + assert results[i]["tag_name"].lower().startswith("1"), ( + f"Tag at index {i} should start with '1' before alias matches" + ) + + def test_search_ranks_popular_tags_higher(self, populated_fts): + """Test that tags with higher post_count rank higher among prefix matches.""" + results = populated_fts.search("1", limit=20) + + # Filter to only tag_name prefix matches + prefix_matches = [r for r in results if r["tag_name"].lower().startswith("1")] + + assert len(prefix_matches) > 1, "Should have multiple prefix matches" + + # Verify descending post_count order among prefix matches + for i in range(len(prefix_matches) - 1): + assert ( + prefix_matches[i]["post_count"] >= prefix_matches[i + 1]["post_count"] + ), ( + f"Tags should be sorted by post_count: {prefix_matches[i]['tag_name']} ({prefix_matches[i]['post_count']}) >= {prefix_matches[i + 1]['tag_name']} ({prefix_matches[i + 1]['post_count']})" + ) + + def test_search_pagination_ordering_consistency(self, populated_fts): + """Test that pagination maintains consistent ordering.""" + page1 = populated_fts.search("1", limit=10, offset=0) + page2 = populated_fts.search("1", limit=10, offset=10) + + assert len(page1) > 0, "Page 1 should have results" + assert len(page2) > 0, "Page 2 should have results" + + # Page 2 scores should all be <= Page 1 min score + page1_min_score = min(r["rank_score"] for r in page1) + page2_max_score = max(r["rank_score"] for r in page2) + + assert page2_max_score <= page1_min_score, ( + f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})" + ) + + def test_search_rank_score_includes_popularity_weight(self, populated_fts): + """Test that rank_score includes post_count popularity weighting.""" + results = populated_fts.search("1", limit=5) + + assert len(results) >= 2, "Need at least 2 results to compare" + + # 1girl has 6M posts, should have higher rank_score than tags with fewer posts + girl_result = next((r for r in results if r["tag_name"] == "1girl"), None) + assert girl_result is not None, "1girl should be in results" + + # Find a tag with significantly fewer posts + low_post_result = next((r for r in results if r["post_count"] < 10000), None) + if low_post_result: + assert girl_result["rank_score"] > low_post_result["rank_score"], ( + f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)" + ) + class TestAliasSearch: """Tests for alias search functionality.""" @@ -204,7 +291,9 @@ class TestAliasSearch: results = populated_fts.search("miku") assert len(results) >= 1 - hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None) + hatsune_result = next( + (r for r in results if r["tag_name"] == "hatsune_miku"), None + ) assert hatsune_result is not None assert hatsune_result["matched_alias"] == "miku" @@ -214,7 +303,9 @@ class TestAliasSearch: results = populated_fts.search("hatsune") assert len(results) >= 1 - hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None) + hatsune_result = next( + (r for r in results if r["tag_name"] == "hatsune_miku"), None + ) assert hatsune_result is not None assert "matched_alias" not in hatsune_result @@ -301,7 +392,9 @@ class TestSlashPrefixAliases: @pytest.fixture def fts_with_slash_aliases(self, temp_db_path): """Create an FTS index with slash-prefixed aliases.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, encoding="utf-8" + ) as f: # Format: tag_name,category,post_count,aliases f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n') f.write('breasts,0,3439214,"/b,boobs,oppai"\n') @@ -380,7 +473,15 @@ class TestCategoryMappings: def test_category_name_to_ids_complete(self): """Test that CATEGORY_NAME_TO_IDS includes all expected names.""" - expected_names = ["general", "artist", "copyright", "character", "meta", "species", "lore"] + expected_names = [ + "general", + "artist", + "copyright", + "character", + "meta", + "species", + "lore", + ] for name in expected_names: assert name in CATEGORY_NAME_TO_IDS assert isinstance(CATEGORY_NAME_TO_IDS[name], list) diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index c1e4c565..421b23a5 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -742,6 +742,14 @@ class AutoComplete { try { this.currentSearchTerm = term; + // Save current search type to detect mode changes during async search + const searchTypeAtStart = this.searchType; + + // Clear items before starting new search to avoid stale data + // This is critical for preventing command suggestions from persisting + // when switching from command mode to regular tag search + this.items = []; + if (!endpoint) { endpoint = `/lm/${this.modelType}/relative-paths`; } @@ -776,7 +784,15 @@ class AutoComplete { const resultsArrays = await Promise.all(searchPromises); - // Merge and deduplicate results + // Check if search type changed during async operation + // If so, skip updating items to prevent stale data from showing + if (this.searchType !== searchTypeAtStart) { + console.log('[Lora Manager] Search type changed during search, skipping update'); + return; + } + + // Merge and deduplicate results while preserving order from backend + // Backend returns results sorted by relevance, so we maintain that order const seen = new Set(); const mergedItems = []; @@ -793,39 +809,10 @@ class AutoComplete { } } - // Score and sort results: exact matches first, then by match quality - const scoredItems = mergedItems.map(item => { - let bestScore = -1; - let isExact = false; - - for (const query of queriesToExecute) { - const match = this._matchItem(item, query); - if (match.matched) { - // Higher score for exact matches - const score = match.isExactMatch ? 1000 : 100; - if (score > bestScore) { - bestScore = score; - isExact = match.isExactMatch; - } - } - } - - return { item, score: bestScore, isExact }; - }); - - // Sort by score (descending), exact matches first - scoredItems.sort((a, b) => { - if (b.isExact !== a.isExact) { - return b.isExact ? 1 : -1; - } - return b.score - a.score; - }); - - // Extract just the items - const sortedItems = scoredItems.map(s => s.item); - - if (sortedItems.length > 0) { - this.items = sortedItems; + // Use backend-sorted results directly without re-scoring + // Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost + if (mergedItems.length > 0) { + this.items = mergedItems; this.render(); this.show(); } else { @@ -908,6 +895,12 @@ class AutoComplete { * @param {string} filter - Optional filter for commands */ _showCommandList(filter = '') { + // Only show command list if we're in command mode + // This prevents stale command suggestions from appearing after switching to tag search + if (this.searchType !== 'commands' && this.showingCommands !== true) { + return; + } + const filterLower = filter.toLowerCase(); // Get unique commands (avoid duplicates like /char and /character) @@ -942,12 +935,20 @@ class AutoComplete { * Render the command list dropdown */ _renderCommandList() { - this.dropdown.innerHTML = ''; + // Clear command list items properly based on rendering mode + if (this.contentContainer) { + // Virtual scrolling mode - clear content container + this.contentContainer.innerHTML = ''; + } else { + // Non-virtual scrolling mode - clear dropdown direct children + this.dropdown.innerHTML = ''; + } this.selectedIndex = -1; this.items.forEach((item, index) => { const itemEl = document.createElement('div'); itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command'; + itemEl.dataset.index = index.toString(); const cmdSpan = document.createElement('span'); cmdSpan.className = 'lm-autocomplete-command-name'; @@ -973,6 +974,8 @@ class AutoComplete { justify-content: space-between; align-items: center; gap: 12px; + height: ${this.options.itemHeight}px; + box-sizing: border-box; `; itemEl.addEventListener('mouseenter', () => { @@ -983,18 +986,29 @@ class AutoComplete { this._insertCommand(item.command); }); - this.dropdown.appendChild(itemEl); + // Append to correct container based on rendering mode + if (this.contentContainer) { + this.contentContainer.appendChild(itemEl); + } else { + this.dropdown.appendChild(itemEl); + } }); // Remove border from last item - if (this.dropdown.lastChild) { - this.dropdown.lastChild.style.borderBottom = 'none'; + const lastChild = this.contentContainer ? this.contentContainer.lastChild : this.dropdown.lastChild; + if (lastChild) { + lastChild.style.borderBottom = 'none'; } // Auto-select first item if (this.items.length > 0) { setTimeout(() => this.selectItem(0), 100); } + + // Update virtual scroll height for virtual scrolling mode + if (this.contentContainer) { + this.updateVirtualScrollHeight(); + } } /** @@ -1057,28 +1071,49 @@ class AutoComplete { } if (this.options.enableVirtualScroll && this.contentContainer) { - // Use virtual scrolling - only update visible items if dropdown is already visible - // If not visible, updateVisibleItems() will be called from show() after display:block + // Use virtual scrolling - always update visible items to ensure content is fresh + // The dropdown visibility is controlled by show()/hide() this.updateVirtualScrollHeight(); - if (this.isVisible && this.dropdown.style.display !== 'none') { - this.updateVisibleItems(); - } + this.updateVisibleItems(); } else { // Traditional rendering (fallback) this.dropdown.innerHTML = ''; - // Check if items are enriched (have tag_name, category, post_count) + // Check if items are enriched (have tag_name, category, post_count) or command objects const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; + const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0]; this.items.forEach((itemData, index) => { const item = document.createElement('div'); item.className = 'comfy-autocomplete-item'; - // Get the display text and path for insertion - const displayText = isEnriched ? itemData.tag_name : itemData; - const insertPath = isEnriched ? itemData.tag_name : itemData; + if (isCommand) { + // Render command item + const cmdSpan = document.createElement('span'); + cmdSpan.className = 'lm-autocomplete-command-name'; + cmdSpan.textContent = itemData.command; - if (isEnriched) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'lm-autocomplete-command-label'; + labelSpan.textContent = itemData.label; + + item.appendChild(cmdSpan); + item.appendChild(labelSpan); + item.style.cssText = ` + padding: 8px 12px; + cursor: pointer; + color: rgba(226, 232, 240, 0.8); + border-bottom: 1px solid rgba(226, 232, 240, 0.1); + transition: all 0.2s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + `; + } else if (isEnriched) { // Render enriched item with category badge and post count this._renderEnrichedItem(item, itemData, this.currentSearchTerm); } else { @@ -1087,7 +1122,7 @@ class AutoComplete { const nameSpan = document.createElement('span'); nameSpan.className = 'lm-autocomplete-name'; // Use display text without extension for cleaner UI - const displayTextWithoutExt = this._getDisplayText(displayText); + const displayTextWithoutExt = this._getDisplayText(itemData); nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm); nameSpan.style.cssText = ` flex: 1; @@ -1096,25 +1131,25 @@ class AutoComplete { text-overflow: ellipsis; `; item.appendChild(nameSpan); + + // Apply item styles with new color scheme + item.style.cssText = ` + padding: 8px 12px; + cursor: pointer; + color: rgba(226, 232, 240, 0.8); + border-bottom: 1px solid rgba(226, 232, 240, 0.1); + transition: all 0.2s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + `; } - // Apply item styles with new color scheme - item.style.cssText = ` - padding: 8px 12px; - cursor: pointer; - color: rgba(226, 232, 240, 0.8); - border-bottom: 1px solid rgba(226, 232, 240, 0.1); - transition: all 0.2s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - `; - // Hover and selection handlers item.addEventListener('mouseenter', () => { this.selectItem(index); @@ -1126,7 +1161,12 @@ class AutoComplete { // Click handler item.addEventListener('click', () => { - this.insertSelection(insertPath); + if (isCommand) { + this._insertCommand(itemData.command); + } else { + const insertPath = isEnriched ? itemData.tag_name : itemData; + this.insertSelection(insertPath); + } }); this.dropdown.appendChild(item); @@ -1369,39 +1409,11 @@ class AutoComplete { this.hasMoreItems = false; } - // If we got new items, add them and re-render + // If we got new items, append them and re-render + // IMPORTANT: Do NOT re-sort! Backend already returns results sorted by relevance if (newItems.length > 0) { - const currentLength = this.items.length; this.items.push(...newItems); - // Re-score and sort all items - const scoredItems = this.items.map(item => { - let bestScore = -1; - let isExact = false; - - for (const query of queriesToExecute) { - const match = this._matchItem(item, query); - if (match.matched) { - const score = match.isExactMatch ? 1000 : 100; - if (score > bestScore) { - bestScore = score; - isExact = match.isExactMatch; - } - } - } - - return { item, score: bestScore, isExact }; - }); - - scoredItems.sort((a, b) => { - if (b.isExact !== a.isExact) { - return b.isExact ? 1 : -1; - } - return b.score - a.score; - }); - - this.items = scoredItems.map(s => s.item); - // Update render if (this.options.enableVirtualScroll) { this.updateVirtualScrollHeight(); @@ -1458,10 +1470,18 @@ class AutoComplete { * Update the total height of the virtual scroll container */ updateVirtualScrollHeight() { - if (!this.contentContainer) return; + if (!this.contentContainer || !this.scrollContainer) return; this.totalHeight = this.items.length * this.options.itemHeight; this.contentContainer.style.height = `${this.totalHeight}px`; + + // Adjust scroll container max-height based on actual content + // Only show scrollbar when content exceeds visibleItems limit + const maxHeight = this.options.visibleItems * this.options.itemHeight; + const shouldShowScrollbar = this.totalHeight > maxHeight; + + this.scrollContainer.style.maxHeight = shouldShowScrollbar ? `${maxHeight}px` : `${this.totalHeight}px`; + this.scrollContainer.style.overflowY = shouldShowScrollbar ? 'auto' : 'hidden'; } /** @@ -1473,11 +1493,12 @@ class AutoComplete { const scrollTop = this.scrollContainer.scrollTop; const containerHeight = this.scrollContainer.clientHeight; - // Calculate which items should be visible - const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2); + // Calculate which items should be visible with a larger buffer for smoother rendering + // Use a fixed buffer of 5 items to ensure selected item is always rendered + const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 5); const endIndex = Math.min( this.items.length - 1, - Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2 + Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 5 ); // Clear current content @@ -1492,10 +1513,11 @@ class AutoComplete { // Render visible items const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; + const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0]; for (let i = startIndex; i <= endIndex; i++) { const itemData = this.items[i]; - const itemEl = this.createItemElement(itemData, i, isEnriched); + const itemEl = this.createItemElement(itemData, i, isEnriched, isCommand); this.contentContainer.appendChild(itemEl); } @@ -1505,12 +1527,22 @@ class AutoComplete { bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`; this.contentContainer.appendChild(bottomSpacer); } + + // Re-apply selection styling after re-rendering + // This ensures the selected item remains highlighted even after DOM updates + if (this.selectedIndex >= startIndex && this.selectedIndex <= endIndex) { + const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${this.selectedIndex}"]`); + if (selectedEl) { + selectedEl.classList.add('comfy-autocomplete-item-selected'); + selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; + } + } } /** * Create a single item element */ - createItemElement(itemData, index, isEnriched) { + createItemElement(itemData, index, isEnriched, isCommand = false) { const item = document.createElement('div'); item.className = 'comfy-autocomplete-item'; item.dataset.index = index.toString(); @@ -1532,16 +1564,31 @@ class AutoComplete { box-sizing: border-box; `; - const displayText = isEnriched ? itemData.tag_name : itemData; - const insertPath = isEnriched ? itemData.tag_name : itemData; + // Check if this is a command object (override parameter if needed) + if (!isCommand && itemData && typeof itemData === 'object' && 'command' in itemData) { + isCommand = true; + } - if (isEnriched) { + if (isCommand) { + // Render command item + const cmdSpan = document.createElement('span'); + cmdSpan.className = 'lm-autocomplete-command-name'; + cmdSpan.textContent = itemData.command; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'lm-autocomplete-command-label'; + labelSpan.textContent = itemData.label; + + item.appendChild(cmdSpan); + item.appendChild(labelSpan); + item.style.gap = '12px'; + } else if (isEnriched) { this._renderEnrichedItem(item, itemData, this.currentSearchTerm); } else { const nameSpan = document.createElement('span'); nameSpan.className = 'lm-autocomplete-name'; // Use display text without extension for cleaner UI - const displayTextWithoutExt = this._getDisplayText(displayText); + const displayTextWithoutExt = this._getDisplayText(itemData); nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm); nameSpan.style.cssText = ` flex: 1; @@ -1561,8 +1608,14 @@ class AutoComplete { this.hidePreview(); }); + // Click handler item.addEventListener('click', () => { - this.insertSelection(insertPath); + if (isCommand) { + this._insertCommand(itemData.command); + } else { + const insertPath = isEnriched ? itemData.tag_name : itemData; + this.insertSelection(insertPath); + } }); return item; @@ -1578,7 +1631,10 @@ class AutoComplete { if (this.options.enableVirtualScroll && this.contentContainer) { this.dropdown.style.display = 'block'; this.isVisible = true; - this.updateVisibleItems(); + // Skip updateVisibleItems if showing commands (already rendered by _renderCommandList) + if (!this.showingCommands) { + this.updateVisibleItems(); + } this.positionAtCursor(); } else { // Position dropdown at cursor position using TextAreaCaretHelper @@ -1638,6 +1694,19 @@ class AutoComplete { this.isVisible = false; this.selectedIndex = -1; this.showingCommands = false; + + // Clear items to prevent stale data from being displayed + // when autocomplete is shown again + this.items = []; + + // Clear content container to prevent stale items from showing + if (this.contentContainer) { + // Virtual scrolling mode - clear content container + this.contentContainer.innerHTML = ''; + } else { + // Non-virtual scrolling mode - clear dropdown direct children + this.dropdown.innerHTML = ''; + } // Reset virtual scrolling state this.virtualScrollOffset = 0; @@ -1688,26 +1757,22 @@ class AutoComplete { // If item is not visible, scroll to make it visible if (itemTop < scrollTop || itemBottom > scrollBottom) { - this.scrollContainer.scrollTop = itemTop - containerHeight / 2; + // Scroll to position the item in the visible area + // Position item at 1/3 from top for better visibility + const targetScrollTop = Math.max(0, itemTop - containerHeight / 3); + this.scrollContainer.scrollTop = targetScrollTop; + // Re-render visible items after scroll this.updateVisibleItems(); - } - - // Find the item element using data-index attribute - const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`); - - if (selectedEl) { - selectedEl.classList.add('comfy-autocomplete-item-selected'); - selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; - - // Show preview for selected item - if (this.options.showPreview) { - if (typeof this.behavior.showPreview === 'function') { - this.behavior.showPreview(this, this.items[index], selectedEl); - } else if (this.previewTooltip) { - this.showPreviewForItem(this.items[index], selectedEl); - } - } + + // Apply selection after DOM is updated + // Use setTimeout to ensure DOM has been re-rendered + setTimeout(() => { + this._applyItemSelection(index); + }, 0); + } else { + // Item is already visible, apply selection immediately + this._applyItemSelection(index); } } else { // Traditional rendering @@ -1731,6 +1796,31 @@ class AutoComplete { } } } + + /** + * Apply selection styling to an item (used after virtual scroll re-render) + * @param {number} index - Index of item to select + */ + _applyItemSelection(index) { + if (!this.contentContainer) return; + + // Find the item element using data-index attribute + const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`); + + if (selectedEl) { + selectedEl.classList.add('comfy-autocomplete-item-selected'); + selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; + + // Show preview for selected item + if (this.options.showPreview) { + if (typeof this.behavior.showPreview === 'function') { + this.behavior.showPreview(this, this.items[index], selectedEl); + } else if (this.previewTooltip) { + this.showPreviewForItem(this.items[index], selectedEl); + } + } + } + } handleKeyDown(e) { if (!this.isVisible) { @@ -1740,12 +1830,39 @@ class AutoComplete { switch (e.key) { case 'ArrowDown': e.preventDefault(); - this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); + if (this.options.enableVirtualScroll && this.scrollContainer) { + // For virtual scrolling, handle boundary cases + if (this.selectedIndex >= this.items.length - 1) { + // Already at last item, try to load more + if (this.hasMoreItems && !this.isLoadingMore) { + this.loadMoreItems().then(() => { + // After loading more, select the next item + if (this.selectedIndex < this.items.length - 1) { + this.selectItem(this.selectedIndex + 1); + } + }); + } + } else { + this.selectItem(this.selectedIndex + 1); + } + } else { + this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); + } break; case 'ArrowUp': e.preventDefault(); - this.selectItem(Math.max(this.selectedIndex - 1, 0)); + if (this.options.enableVirtualScroll && this.scrollContainer) { + // For virtual scrolling, handle top boundary + if (this.selectedIndex <= 0) { + // Already at first item, ensure it's selected + this.selectItem(0); + } else { + this.selectItem(this.selectedIndex - 1); + } + } else { + this.selectItem(Math.max(this.selectedIndex - 1, 0)); + } break; case 'Enter':