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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SelectItem>;
|
||||
#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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user