From 42b79bc696ef48ac1709ab2280fe6e6dffda2aeb Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 31 May 2026 07:45:22 +0200 Subject: [PATCH] feat(coding-agent): added fuzzy token matching in coding-agent components - Added fuzzy token matching in agent-dashboard, state-manager, and tree-selector, replacing lowercased checks. - Added search-query state and fuzzy-filter helpers to hook, oauth, and user-message selectors for query filtering. - Updated filtered selectors to render match results, status lines, no-match text, and move selection within results. - Added `overflowSearch` and filter state to `SelectList`, switching overflowing list matching to fuzzy checks. - Configured `SelectList` input flow and fixed cancel so Escape/Ctrl+C closes lists when no matches exist. - Updated changelogs and added tests for fuzzy-filter behavior in hook, oauth, user-message, and list selectors. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/components/agent-dashboard.ts | 12 +- .../components/extensions/state-manager.ts | 7 +- .../src/modes/components/hook-selector.ts | 106 +++++++++++++--- .../src/modes/components/oauth-selector.ts | 114 ++++++++++++++---- .../src/modes/components/tree-selector.ts | 7 +- .../modes/components/user-message-selector.ts | 113 ++++++++++++++--- .../components/hook-selector-slider.test.ts | 25 ++++ .../modes/components/oauth-selector.test.ts | 48 ++++++++ .../components/user-message-selector.test.ts | 38 ++++++ packages/tui/CHANGELOG.md | 9 ++ packages/tui/src/components/editor.ts | 12 +- packages/tui/src/components/select-list.ts | 108 ++++++++++++++--- packages/tui/test/select-list.test.ts | 35 ++++++ 14 files changed, 541 insertions(+), 94 deletions(-) create mode 100644 packages/coding-agent/test/modes/components/oauth-selector.test.ts create mode 100644 packages/coding-agent/test/modes/components/user-message-selector.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a26495c7..a1b9fb246 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Changed +- Changed overflowing provider, hook-option, branch-message, agent, extension, and session-tree pickers to support fuzzy type-to-filter search. - Changed Shift+Ctrl+P to cycle role models backward instead of cycling forward without persisting. - Changed empty prompt input so `?` inserts a literal question mark instead of opening `/hotkeys`; use `/hotkeys` explicitly for the shortcut reference. - Changed `search` output to preserve full virtual and internal URL paths in grouped results and `details.files` instead of collapsing them to file basenames diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index e4336dc80..1ce921ca7 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -21,6 +21,7 @@ import { type Component, Container, extractPrintableText, + fuzzyMatch, Input, matchesKey, padding, @@ -110,12 +111,11 @@ function formatResolution(resolution: ModelResolution): string { } function matchAgent(agent: DashboardAgent, query: string): boolean { - const q = query.toLowerCase(); - if (agent.name.toLowerCase().includes(q)) return true; - if (agent.description.toLowerCase().includes(q)) return true; - if (SOURCE_LABEL[agent.source].toLowerCase().includes(q)) return true; - if (agent.overrideModel?.toLowerCase().includes(q)) return true; - return false; + const text = `${agent.name} ${agent.description} ${SOURCE_LABEL[agent.source]} ${agent.overrideModel ?? ""}`; + return query + .trim() + .split(/\s+/) + .every(token => fuzzyMatch(token, text).matches); } function extractAssistantText(messages: AgentMessage[]): string | null { diff --git a/packages/coding-agent/src/modes/components/extensions/state-manager.ts b/packages/coding-agent/src/modes/components/extensions/state-manager.ts index 016d592b8..82adfadb0 100644 --- a/packages/coding-agent/src/modes/components/extensions/state-manager.ts +++ b/packages/coding-agent/src/modes/components/extensions/state-manager.ts @@ -3,6 +3,7 @@ * Handles data loading, tree building, filtering, and toggle persistence. */ import * as path from "node:path"; +import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import { logger } from "@oh-my-pi/pi-utils"; import type { ContextFile } from "../../../capability/context-file"; import type { ExtensionModule } from "../../../capability/extension-module"; @@ -404,11 +405,9 @@ export function applyFilter(extensions: Extension[], query: string): Extension[] ext.trigger || "", ext.source.providerName, ext.kind, - ] - .join(" ") - .toLowerCase(); + ].join(" "); - return tokens.every(token => searchable.includes(token)); + return tokens.every(token => fuzzyMatch(token, searchable).matches); }); } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 8e1ee70d7..19aab9400 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -4,6 +4,8 @@ */ import { Container, + extractPrintableText, + fuzzyFilter, Markdown, matchesKey, padding, @@ -91,6 +93,8 @@ class OutlinedList extends Container { export class HookSelectorComponent extends Container { #options: string[]; + #filteredOptions: string[]; + #searchQuery = ""; #selectedIndex: number; #maxVisible: number; #listContainer: Container | undefined; @@ -116,7 +120,8 @@ export class HookSelectorComponent extends Container { super(); this.#options = options; - this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1); + this.#filteredOptions = options; + this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1); this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12); this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; @@ -149,8 +154,7 @@ export class HookSelectorComponent extends Container { s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), () => { opts?.onTimeout?.(); - // Auto-select current option on timeout (typically the first/recommended option) - const selected = this.#options[this.#selectedIndex]; + const selected = this.#filteredOptions[this.#selectedIndex]; if (selected) { this.#onSelectCallback(selected); } else { @@ -178,24 +182,31 @@ export class HookSelectorComponent extends Container { #updateList(): void { const lines: string[] = []; + const total = this.#filteredOptions.length; const startIndex = Math.max( 0, - Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#options.length - this.#maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length); + const endIndex = Math.min(startIndex + this.#maxVisible, total); const mdTheme = getMarkdownTheme(); for (let i = startIndex; i < endIndex; i++) { + const option = this.#filteredOptions[i]; + if (option === undefined) continue; const isSelected = i === this.#selectedIndex; const label = isSelected - ? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t)) - : renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t)); + ? renderInlineMarkdown(option, mdTheme, t => theme.fg("accent", t)) + : renderInlineMarkdown(option, mdTheme, t => theme.fg("text", t)); const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " "; lines.push(prefix + label); } - if (startIndex > 0 || endIndex < this.#options.length) { - lines.push(theme.fg("dim", ` (${this.#selectedIndex + 1}/${this.#options.length})`)); + if (total === 0) { + lines.push(theme.fg("dim", " No matching options")); + } + + if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) { + lines.push(this.#renderStatusLine(total)); } if (this.#outlinedList) { this.#outlinedList.setLines(lines); @@ -242,29 +253,84 @@ export class HookSelectorComponent extends Container { slider.onChange?.(next); } + #isSearchEnabled(): boolean { + return this.#options.length > this.#maxVisible; + } + + #shouldRenderSearchStatus(): boolean { + return this.#isSearchEnabled() || this.#searchQuery.length > 0; + } + + #renderStatusLine(total: number): string { + const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1; + const count = + this.#searchQuery.trim() && total !== this.#options.length + ? `${selectedCount}/${total} of ${this.#options.length}` + : `${selectedCount}/${total}`; + const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search"; + return theme.fg("dim", ` (${count})${suffix}`); + } + + #setSearchQuery(query: string): void { + this.#searchQuery = query; + this.#filteredOptions = query.trim() ? fuzzyFilter(this.#options, query, option => option) : this.#options; + this.#selectedIndex = 0; + this.#updateList(); + } + + #handleSearchInput(keyData: string): boolean { + if (!this.#isSearchEnabled()) return false; + + if (matchesKey(keyData, "backspace")) { + if (this.#searchQuery.length === 0) return false; + const chars = [...this.#searchQuery]; + chars.pop(); + this.#setSearchQuery(chars.join("")); + return true; + } + + const printableText = extractPrintableText(keyData); + if (printableText === undefined) return false; + if (this.#searchQuery.length === 0 && printableText.trim().length === 0) return false; + + this.#setSearchQuery(this.#searchQuery + printableText); + return true; + } + handleInput(keyData: string): void { // Reset countdown on any interaction this.#countdown?.reset(); - if (matchesSelectUp(keyData) || keyData === "k") { - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); - this.#updateList(); - } else if (matchesSelectDown(keyData) || keyData === "j") { - this.#selectedIndex = Math.min(this.#options.length - 1, this.#selectedIndex + 1); - this.#updateList(); + if (matchesSelectCancel(keyData)) { + this.#onCancelCallback(); + return; + } + + if (this.#handleSearchInput(keyData)) { + return; + } + + if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) { + if (this.#filteredOptions.length > 0) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + this.#updateList(); + } + } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) { + if (this.#filteredOptions.length > 0) { + this.#selectedIndex = Math.min(this.#filteredOptions.length - 1, this.#selectedIndex + 1); + this.#updateList(); + } } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.#options[this.#selectedIndex]; + const selected = this.#filteredOptions[this.#selectedIndex]; if (selected) this.#onSelectCallback(selected); - } else if (matchesKey(keyData, "left") || (this.#slider && keyData === "h")) { + } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) { if (this.#slider) this.#moveSlider(-1); else this.#onLeftCallback?.(); - } else if (matchesKey(keyData, "right") || (this.#slider && keyData === "l")) { + } else if (matchesKey(keyData, "right") || (this.#slider && !this.#isSearchEnabled() && keyData === "l")) { if (this.#slider) this.#moveSlider(1); else this.#onRightCallback?.(); } else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) { this.#onExternalEditorCallback(); - } else if (matchesSelectCancel(keyData)) { - this.#onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 3aca7df35..8ec246621 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -1,6 +1,6 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types"; -import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; +import { Container, extractPrintableText, fuzzyFilter, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import type { AuthStorage } from "../../session/auth-storage"; @@ -13,6 +13,8 @@ const OAUTH_SELECTOR_MAX_VISIBLE = 10; export class OAuthSelectorComponent extends Container { #listContainer: Container; #allProviders: OAuthProviderInfo[] = []; + #filteredProviders: OAuthProviderInfo[] = []; + #searchQuery = ""; #selectedIndex: number = 0; #mode: "login" | "logout"; #authStorage: AuthStorage; @@ -67,6 +69,7 @@ export class OAuthSelectorComponent extends Container { } #loadProviders(): void { this.#allProviders = getOAuthProviders(); + this.#filteredProviders = this.#allProviders; } #startValidation(): void { @@ -144,10 +147,68 @@ export class OAuthSelectorComponent extends Container { } return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : ""; } + #isSearchEnabled(): boolean { + return this.#allProviders.length > OAUTH_SELECTOR_MAX_VISIBLE; + } + + #shouldRenderSearchStatus(): boolean { + return this.#isSearchEnabled() || this.#searchQuery.length > 0; + } + + #renderStatusLine(total: number): string { + const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1; + const count = + this.#searchQuery.trim() && total !== this.#allProviders.length + ? `${selectedCount}/${total} of ${this.#allProviders.length}` + : `${selectedCount}/${total}`; + const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search"; + return theme.fg("muted", ` (${count})${suffix}`); + } + + #getProviderSearchText(provider: OAuthProviderInfo): string { + let text = `${provider.name} ${provider.id}`; + if (this.#authStorage.hasAuth(provider.id)) { + text += " logged in authenticated"; + } + if (!provider.available) { + text += " unavailable"; + } + return text; + } + + #setSearchQuery(query: string): void { + this.#searchQuery = query; + this.#filteredProviders = query.trim() + ? fuzzyFilter(this.#allProviders, query, provider => this.#getProviderSearchText(provider)) + : this.#allProviders; + this.#selectedIndex = 0; + this.#statusMessage = undefined; + this.#updateList(); + } + + #handleSearchInput(keyData: string): boolean { + if (!this.#isSearchEnabled()) return false; + + if (matchesKey(keyData, "backspace")) { + if (this.#searchQuery.length === 0) return false; + const chars = [...this.#searchQuery]; + chars.pop(); + this.#setSearchQuery(chars.join("")); + return true; + } + + const printableText = extractPrintableText(keyData); + if (printableText === undefined) return false; + if (this.#searchQuery.length === 0 && printableText.trim().length === 0) return false; + + this.#setSearchQuery(this.#searchQuery + printableText); + return true; + } + #updateList(): void { this.#listContainer.clear(); - const total = this.#allProviders.length; + const total = this.#filteredProviders.length; const maxVisible = OAUTH_SELECTOR_MAX_VISIBLE; const startIndex = total <= maxVisible @@ -156,7 +217,7 @@ export class OAuthSelectorComponent extends Container { const endIndex = Math.min(startIndex + maxVisible, total); for (let i = startIndex; i < endIndex; i++) { - const provider = this.#allProviders[i]; + const provider = this.#filteredProviders[i]; if (!provider) continue; const isSelected = i === this.#selectedIndex; const isAvailable = provider.available; @@ -174,16 +235,19 @@ export class OAuthSelectorComponent extends Container { this.#listContainer.addChild(new TruncatedText(line, 0, 0)); } - // Scroll indicator when list is windowed - if (startIndex > 0 || endIndex < total) { - const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${total})`); - this.#listContainer.addChild(new TruncatedText(scrollInfo, 0, 0)); + // Scroll/search indicator when list is windowed or searchable + if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) { + this.#listContainer.addChild(new TruncatedText(this.#renderStatusLine(total), 0, 0)); } // Show "no providers" if empty if (total === 0) { const message = - this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; + this.#allProviders.length === 0 + ? this.#mode === "login" + ? "No OAuth providers available" + : "No OAuth providers logged in. Use /login first." + : "No matching providers"; this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); } if (this.#statusMessage) { @@ -192,25 +256,38 @@ export class OAuthSelectorComponent extends Container { } } handleInput(keyData: string): void { + // Escape or Ctrl+C + if (matchesSelectCancel(keyData)) { + this.stopValidation(); + this.#onCancelCallback(); + return; + } + + if (this.#handleSearchInput(keyData)) { + return; + } + // Up arrow if (matchesSelectUp(keyData)) { - if (this.#allProviders.length > 0) { - this.#selectedIndex = this.#selectedIndex === 0 ? this.#allProviders.length - 1 : this.#selectedIndex - 1; + if (this.#filteredProviders.length > 0) { + this.#selectedIndex = + this.#selectedIndex === 0 ? this.#filteredProviders.length - 1 : this.#selectedIndex - 1; } this.#statusMessage = undefined; this.#updateList(); } // Down arrow else if (matchesSelectDown(keyData)) { - if (this.#allProviders.length > 0) { - this.#selectedIndex = this.#selectedIndex === this.#allProviders.length - 1 ? 0 : this.#selectedIndex + 1; + if (this.#filteredProviders.length > 0) { + this.#selectedIndex = + this.#selectedIndex === this.#filteredProviders.length - 1 ? 0 : this.#selectedIndex + 1; } this.#statusMessage = undefined; this.#updateList(); } // Page up - jump up by one visible page else if (matchesKey(keyData, "pageUp")) { - if (this.#allProviders.length > 0) { + if (this.#filteredProviders.length > 0) { this.#selectedIndex = Math.max(0, this.#selectedIndex - OAUTH_SELECTOR_MAX_VISIBLE); } this.#statusMessage = undefined; @@ -218,9 +295,9 @@ export class OAuthSelectorComponent extends Container { } // Page down - jump down by one visible page else if (matchesKey(keyData, "pageDown")) { - if (this.#allProviders.length > 0) { + if (this.#filteredProviders.length > 0) { this.#selectedIndex = Math.min( - this.#allProviders.length - 1, + this.#filteredProviders.length - 1, this.#selectedIndex + OAUTH_SELECTOR_MAX_VISIBLE, ); } @@ -229,7 +306,7 @@ export class OAuthSelectorComponent extends Container { } // Enter else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selectedProvider = this.#allProviders[this.#selectedIndex]; + const selectedProvider = this.#filteredProviders[this.#selectedIndex]; if (selectedProvider?.available) { this.#statusMessage = undefined; this.stopValidation(); @@ -239,10 +316,5 @@ export class OAuthSelectorComponent extends Container { this.#updateList(); } } - // Escape or Ctrl+C - else if (matchesSelectCancel(keyData)) { - this.stopValidation(); - this.#onCancelCallback(); - } } } diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index d489d824c..c2df593aa 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -3,6 +3,7 @@ import { type Component, Container, extractPrintableText, + fuzzyMatch, Input, matchesKey, Spacer, @@ -325,10 +326,10 @@ class TreeList implements Component { if (!passesFilter) return false; - // Apply search filter + // Apply fuzzy search filter if (searchTokens.length > 0) { - const nodeText = this.#getSearchableText(flatNode.node).toLowerCase(); - return searchTokens.every(token => nodeText.includes(token)); + const nodeText = this.#getSearchableText(flatNode.node); + return searchTokens.every(token => fuzzyMatch(token, nodeText).matches); } return true; diff --git a/packages/coding-agent/src/modes/components/user-message-selector.ts b/packages/coding-agent/src/modes/components/user-message-selector.ts index b0bd6b130..d67aabe38 100644 --- a/packages/coding-agent/src/modes/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/components/user-message-selector.ts @@ -1,4 +1,13 @@ -import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { + type Component, + Container, + extractPrintableText, + fuzzyFilter, + matchesKey, + Spacer, + Text, + truncateToWidth, +} from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; @@ -13,6 +22,8 @@ interface UserMessageItem { * Custom user message list component with selection */ class UserMessageList implements Component { + #filteredMessages: UserMessageItem[]; + #searchQuery = ""; #selectedIndex: number = 0; onSelect?: (entryId: string) => void; onCancel?: () => void; @@ -20,14 +31,60 @@ class UserMessageList implements Component { constructor(private readonly messages: UserMessageItem[]) { // Store messages in chronological order (oldest to newest) + this.#filteredMessages = messages; // Start with the last (most recent) message selected - this.#selectedIndex = Math.max(0, this.messages.length - 1); + this.#selectedIndex = Math.max(0, this.#filteredMessages.length - 1); } invalidate(): void { // No cached state to invalidate currently } + #isSearchEnabled(): boolean { + return this.messages.length > this.#maxVisible; + } + + #shouldRenderSearchStatus(): boolean { + return this.#isSearchEnabled() || this.#searchQuery.length > 0; + } + + #renderStatusLine(total: number): string { + const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1; + const count = + this.#searchQuery.trim() && total !== this.messages.length + ? `${selectedCount}/${total} of ${this.messages.length}` + : `${selectedCount}/${total}`; + const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search"; + return theme.fg("muted", ` (${count})${suffix}`); + } + + #setSearchQuery(query: string): void { + this.#searchQuery = query; + this.#filteredMessages = query.trim() + ? fuzzyFilter(this.messages, query, message => `${message.text} ${message.timestamp ?? ""}`) + : this.messages; + this.#selectedIndex = query.trim() ? 0 : Math.max(0, this.#filteredMessages.length - 1); + } + + #handleSearchInput(keyData: string): boolean { + if (!this.#isSearchEnabled()) return false; + + if (matchesKey(keyData, "backspace")) { + if (this.#searchQuery.length === 0) return false; + const chars = [...this.#searchQuery]; + chars.pop(); + this.#setSearchQuery(chars.join("")); + return true; + } + + const printableText = extractPrintableText(keyData); + if (printableText === undefined) return false; + if (this.#searchQuery.length === 0 && printableText.trim().length === 0) return false; + + this.#setSearchQuery(this.#searchQuery + printableText); + return true; + } + render(width: number): string[] { const lines: string[] = []; @@ -36,16 +93,19 @@ class UserMessageList implements Component { return lines; } + const total = this.#filteredMessages.length; + // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.messages.length - this.#maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.#maxVisible, this.messages.length); + const endIndex = Math.min(startIndex + this.#maxVisible, total); // Render visible messages (2 lines per message + blank line) for (let i = startIndex; i < endIndex; i++) { - const message = this.messages[i]; + const message = this.#filteredMessages[i]; + if (!message) continue; const isSelected = i === this.#selectedIndex; // Normalize message to single line @@ -60,44 +120,59 @@ class UserMessageList implements Component { lines.push(messageLine); // Second line: metadata (position in history) - const position = i + 1; + const position = this.messages.indexOf(message) + 1; const metadata = ` Message ${position} of ${this.messages.length}`; const metadataLine = theme.fg("muted", metadata); lines.push(metadataLine); lines.push(""); // Blank line between messages } - // Add scroll indicator if needed - if (startIndex > 0 || endIndex < this.messages.length) { - const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.messages.length})`); - lines.push(scrollInfo); + if (total === 0) { + lines.push(theme.fg("muted", " No matching messages")); + } + + // Add scroll/search indicator if needed + if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) { + lines.push(this.#renderStatusLine(total)); } return lines; } handleInput(keyData: string): void { + // Escape / cancel + if (matchesSelectCancel(keyData)) { + if (this.onCancel) { + this.onCancel(); + } + return; + } + + if (this.#handleSearchInput(keyData)) { + return; + } + // Up arrow - go to previous (older) message, wrap to bottom when at top if (matchesSelectUp(keyData)) { - this.#selectedIndex = this.#selectedIndex === 0 ? this.messages.length - 1 : this.#selectedIndex - 1; + if (this.#filteredMessages.length > 0) { + this.#selectedIndex = + this.#selectedIndex === 0 ? this.#filteredMessages.length - 1 : this.#selectedIndex - 1; + } } // Down arrow - go to next (newer) message, wrap to top when at bottom else if (matchesSelectDown(keyData)) { - this.#selectedIndex = this.#selectedIndex === this.messages.length - 1 ? 0 : this.#selectedIndex + 1; + if (this.#filteredMessages.length > 0) { + this.#selectedIndex = + this.#selectedIndex === this.#filteredMessages.length - 1 ? 0 : this.#selectedIndex + 1; + } } // Enter - select message and branch else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.messages[this.#selectedIndex]; + const selected = this.#filteredMessages[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.id); } } - // Escape / cancel - else if (matchesSelectCancel(keyData)) { - if (this.onCancel) { - this.onCancel(); - } - } } } diff --git a/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts b/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts index 9bfa320e7..2ef719707 100644 --- a/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts +++ b/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts @@ -128,4 +128,29 @@ describe("HookSelectorComponent model slider", () => { h.component.handleInput(RIGHT); expect([left, right]).toEqual([1, 1]); }); + + it("fuzzy-filters overflowing option lists from typed input", () => { + const selected: string[] = []; + const component = new HookSelectorComponent( + "Choose provider", + ["Ollama", "Kagi", "OpenCode Go", "Tavily"], + option => selected.push(option), + () => {}, + { maxVisible: 3 }, + ); + + component.handleInput("o"); + component.handleInput("g"); + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + + expect(rendered).toContain("OpenCode Go"); + expect(rendered).not.toContain("Ollama"); + expect(rendered).toContain("Search: og"); + + component.handleInput("\n"); + expect(selected).toEqual(["OpenCode Go"]); + }); }); diff --git a/packages/coding-agent/test/modes/components/oauth-selector.test.ts b/packages/coding-agent/test/modes/components/oauth-selector.test.ts new file mode 100644 index 000000000..eb72f62bc --- /dev/null +++ b/packages/coding-agent/test/modes/components/oauth-selector.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { OAuthSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/oauth-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; + +beforeAll(async () => { + await initTheme(); +}); + +const authStorage = { + hasAuth: (_providerId: string) => false, +} as unknown as AuthStorage; + +describe("OAuthSelectorComponent", () => { + it("fuzzy-filters overflowing provider lists from typed input", () => { + const providers = getOAuthProviders(); + expect(providers.length).toBeGreaterThan(10); + const target = + providers.find(provider => provider.available && provider.id === "vllm") ?? + providers.find(provider => provider.available) ?? + providers[0]; + expect(target).toBeDefined(); + if (!target) return; + + const selected: string[] = []; + const component = new OAuthSelectorComponent( + "login", + authStorage, + providerId => selected.push(providerId), + () => {}, + ); + + for (const char of target.id) { + component.handleInput(char); + } + + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain(target.name); + expect(rendered).toContain(`Search: ${target.id}`); + + component.handleInput("\n"); + expect(selected).toEqual([target.id]); + }); +}); diff --git a/packages/coding-agent/test/modes/components/user-message-selector.test.ts b/packages/coding-agent/test/modes/components/user-message-selector.test.ts new file mode 100644 index 000000000..0161fbe63 --- /dev/null +++ b/packages/coding-agent/test/modes/components/user-message-selector.test.ts @@ -0,0 +1,38 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { UserMessageSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/user-message-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +beforeAll(async () => { + await initTheme(); +}); + +describe("UserMessageSelectorComponent", () => { + it("fuzzy-filters overflowing message lists from typed input", () => { + const selected: string[] = []; + const messages = Array.from({ length: 11 }, (_, index) => ({ + id: `message-${index}`, + text: index === 7 ? "Deploy the needle rollback plan" : `Routine status update ${index}`, + })); + const component = new UserMessageSelectorComponent( + messages, + id => selected.push(id), + () => {}, + ); + const list = component.getMessageList(); + + for (const char of "needle") { + list.handleInput(char); + } + + const rendered = component + .render(80) + .map(line => Bun.stripANSI(line)) + .join("\n"); + expect(rendered).toContain("Deploy the needle rollback plan"); + expect(rendered).not.toContain("Routine status update"); + expect(rendered).toContain("Search: needle"); + + list.handleInput("\n"); + expect(selected).toEqual(["message-7"]); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 506c3dc6b..bd78ea439 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,9 +1,18 @@ # Changelog ## [Unreleased] +### Added + +- Added `overflowSearch` to `SelectListLayoutOptions` to let consumers enable or disable type-to-filter search and search-status rendering per SelectList instance +- Added fuzzy type-to-filter search to overflowing `SelectList` pickers, with search status and result counts. + +### Changed + +- Disabled interactive search filtering for editor autocomplete and slash-command `SelectList`s by passing `overflowSearch: false` in their layout options ### Fixed +- Fixed `tui.select.cancel` handling in `SelectList` so pressing Escape or Ctrl+C closes the list even when no matches are currently shown - Fixed native scrollback corruption when an offscreen row edit and repeated-tail append land in one render frame; ambiguous appended tails now rebuild history instead of splicing stale rows into the buffer. ## [15.7.0] - 2026-05-31 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 4a34f3372..326ade5f7 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -19,9 +19,14 @@ import { } from "../utils"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list"; +const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + overflowSearch: false, +}; + const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 32, + overflowSearch: false, }; function sanitizeLoadedText(text: string): string { @@ -2524,11 +2529,8 @@ export class Editor implements Component, Focusable { prefix: string, items: Array<{ value: string; label: string; description?: string }>, ): SelectList { - // Layout options prepared for future SelectList enhancements (e.g., for slash commands) - const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined; - // TODO: Pass layout to SelectList when constructor is updated to support it - void layout; // Use layout variable to avoid lint warnings - return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList); + const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT; + return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout); } #handleTabCompletion(): void { diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 25ee62f0c..5387fda44 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,6 @@ +import { fuzzyFilter } from "../fuzzy"; import { getKeybindings } from "../keybindings"; +import { extractPrintableText } from "../keys"; import type { SymbolTheme } from "../symbols"; import type { Component } from "../tui"; import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils"; @@ -45,10 +47,13 @@ export interface SelectListLayoutOptions { minPrimaryColumnWidth?: number; maxPrimaryColumnWidth?: number; truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; + /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */ + overflowSearch?: boolean; } export class SelectList implements Component { #filteredItems: ReadonlyArray; + #filterQuery = ""; #selectedIndex: number = 0; onSelect?: (item: SelectItem) => void; @@ -65,9 +70,7 @@ export class SelectList implements Component { } setFilter(filter: string): void { - this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase())); - // Reset selection when filter changes - this.#selectedIndex = 0; + this.#setFilter(filter, true); } setSelectedIndex(index: number): void { @@ -80,10 +83,14 @@ export class SelectList implements Component { render(width: number): string[] { const lines: string[] = []; + const showSearchStatus = this.#shouldRenderSearchStatus(); // If no items match filter, show message if (this.#filteredItems.length === 0) { - lines.push(this.theme.noMatch(" No matching commands")); + if (showSearchStatus) { + lines.push(this.#renderStatusLine(width)); + } + lines.push(this.theme.noMatch(" No matching items")); return lines; } @@ -106,19 +113,29 @@ export class SelectList implements Component { lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth)); } - // Add scroll indicators if needed - if (startIndex > 0 || endIndex < this.#filteredItems.length) { - const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`; - // Truncate if too long for terminal - lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit))); + // Add scroll/search status when needed + if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) { + lines.push(this.#renderStatusLine(width)); } return lines; } handleInput(keyData: string): void { - if (this.#filteredItems.length === 0) return; const kb = getKeybindings(); + // Escape or Ctrl+C + if (kb.matches(keyData, "tui.select.cancel")) { + if (this.onCancel) { + this.onCancel(); + } + return; + } + + if (this.#handleSearchInput(keyData)) { + return; + } + + if (this.#filteredItems.length === 0) return; // Up arrow - wrap to bottom when at top if (kb.matches(keyData, "tui.select.up")) { this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1; @@ -146,12 +163,6 @@ export class SelectList implements Component { this.onSelect(selectedItem); } } - // Escape or Ctrl+C - else if (kb.matches(keyData, "tui.select.cancel")) { - if (this.onCancel) { - this.onCancel(); - } - } } #renderItem( @@ -235,6 +246,71 @@ export class SelectList implements Component { return sanitizeSingleLine(item.label || item.value); } + #renderStatusLine(width: number): string { + const selectedCount = this.#filteredItems.length === 0 ? 0 : this.#selectedIndex + 1; + const filteredCount = this.#filteredItems.length; + const count = + this.#filterQuery.trim() && filteredCount !== this.items.length + ? `${selectedCount}/${filteredCount} of ${this.items.length}` + : `${selectedCount}/${filteredCount}`; + const query = sanitizeSingleLine(this.#filterQuery); + const searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : ""; + const statusText = ` (${count})${searchSuffix}`; + return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit)); + } + + #shouldRenderSearchStatus(): boolean { + return ( + this.layout.overflowSearch !== false && (this.items.length > this.maxVisible || this.#filterQuery.length > 0) + ); + } + + #canEditSearch(): boolean { + return this.layout.overflowSearch !== false && this.items.length > this.maxVisible; + } + + #handleSearchInput(keyData: string): boolean { + if (!this.#canEditSearch()) return false; + + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.editor.deleteCharBackward")) { + if (this.#filterQuery.length === 0) return false; + const chars = [...this.#filterQuery]; + chars.pop(); + this.#setFilter(chars.join(""), true); + return true; + } + + const printableText = extractPrintableText(keyData); + if (printableText === undefined) return false; + if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false; + + this.#setFilter(this.#filterQuery + printableText, true); + return true; + } + + #setFilter(filter: string, notify: boolean): void { + this.#filterQuery = filter; + this.#filteredItems = filter.trim() + ? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item)) + : this.items; + this.#selectedIndex = 0; + if (notify) { + this.#notifySelectionChange(); + } + } + + #getFilterText(item: SelectItem): string { + let text = `${item.label} ${item.value}`; + if (item.description) { + text += ` ${item.description}`; + } + if (item.hint) { + text += ` ${item.hint}`; + } + return sanitizeSingleLine(text); + } + #notifySelectionChange(): void { const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem && this.onSelectionChange) { diff --git a/packages/tui/test/select-list.test.ts b/packages/tui/test/select-list.test.ts index f9a92765c..a7026074b 100644 --- a/packages/tui/test/select-list.test.ts +++ b/packages/tui/test/select-list.test.ts @@ -168,4 +168,39 @@ describe("SelectList", () => { expect(selectedValue).toBe("run"); }); + + it("fuzzy-filters overflowing lists from typed input", () => { + const items = [ + { value: "ollama", label: "Ollama" }, + { value: "kagi", label: "Kagi" }, + { value: "opencode-go", label: "OpenCode Go" }, + { value: "tavily", label: "Tavily" }, + ]; + const list = new SelectList(items, 3, testTheme); + + list.handleInput("o"); + list.handleInput("g"); + + const rendered = list.render(80).join("\n"); + expect(rendered).toContain("OpenCode Go"); + expect(rendered).not.toContain("Ollama"); + expect(rendered).toContain("Search: og"); + expect(list.getSelectedItem()?.value).toBe("opencode-go"); + }); + + it("keeps printable keys inert when the list does not overflow", () => { + const items = [ + { value: "alpha", label: "Alpha" }, + { value: "beta", label: "Beta" }, + ]; + const list = new SelectList(items, 2, testTheme); + + list.handleInput("b"); + + const rendered = list.render(80).join("\n"); + expect(rendered).toContain("Alpha"); + expect(rendered).toContain("Beta"); + expect(rendered).not.toContain("Search:"); + expect(list.getSelectedItem()?.value).toBe("alpha"); + }); });