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:
can1357
2026-05-31 07:45:22 +02:00
parent ae5139358c
commit 42b79bc696
14 changed files with 541 additions and 94 deletions
+1
View File
@@ -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"]);
});
});
+9
View File
@@ -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
+7 -5
View File
@@ -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 {
+92 -16
View File
@@ -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) {
+35
View File
@@ -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");
});
});