diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 35eed241..e27eef49 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -309,6 +309,7 @@ export class SettingsSelectorComponent extends Container { } }, callbacks.onCancel, + { enableSearch: true }, ); this.addChild(this.settingsList); diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 051b6502..9357906a 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,8 @@ +import { fuzzyFilter } from "../fuzzy.js"; import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; +import { Input } from "./input.js"; export interface SettingItem { /** Unique identifier for this setting */ @@ -25,13 +27,20 @@ export interface SettingsListTheme { hint: (text: string) => string; } +export interface SettingsListOptions { + enableSearch?: boolean; +} + export class SettingsList implements Component { private items: SettingItem[]; + private filteredItems: SettingItem[]; private theme: SettingsListTheme; private selectedIndex = 0; private maxVisible: number; private onChange: (id: string, newValue: string) => void; private onCancel: () => void; + private searchInput?: Input; + private searchEnabled: boolean; // Submenu state private submenuComponent: Component | null = null; @@ -43,12 +52,18 @@ export class SettingsList implements Component { theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, + options: SettingsListOptions = {}, ) { this.items = items; + this.filteredItems = items; this.maxVisible = maxVisible; this.theme = theme; this.onChange = onChange; this.onCancel = onCancel; + this.searchEnabled = options.enableSearch ?? false; + if (this.searchEnabled) { + this.searchInput = new Input(); + } } /** Update an item's currentValue */ @@ -75,24 +90,39 @@ export class SettingsList implements Component { private renderMainList(width: number): string[] { const lines: string[] = []; + if (this.searchEnabled && this.searchInput) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } + if (this.items.length === 0) { lines.push(this.theme.hint(" No settings available")); + if (this.searchEnabled) { + this.addHintLine(lines); + } + return lines; + } + + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (displayItems.length === 0) { + lines.push(this.theme.hint(" No matching settings")); + this.addHintLine(lines); return lines; } // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); + const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); // Calculate max label width for alignment const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); // Render visible items for (let i = startIndex; i < endIndex; i++) { - const item = this.items[i]; + const item = displayItems[i]; if (!item) continue; const isSelected = i === this.selectedIndex; @@ -114,13 +144,13 @@ export class SettingsList implements Component { } // Add scroll indicator if needed - if (startIndex > 0 || endIndex < this.items.length) { - const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; + if (startIndex > 0 || endIndex < displayItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); } // Add description for selected item - const selectedItem = this.items[this.selectedIndex]; + const selectedItem = displayItems[this.selectedIndex]; if (selectedItem?.description) { lines.push(""); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); @@ -130,8 +160,7 @@ export class SettingsList implements Component { } // Add hint - lines.push(""); - lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel")); + this.addHintLine(lines); return lines; } @@ -146,19 +175,29 @@ export class SettingsList implements Component { // Main list input handling const kb = getEditorKeybindings(); + const displayItems = this.searchEnabled ? this.filteredItems : this.items; if (kb.matches(data, "selectUp")) { - this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; } else if (kb.matches(data, "selectDown")) { - this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; } else if (kb.matches(data, "selectConfirm") || data === " ") { this.activateItem(); } else if (kb.matches(data, "selectCancel")) { this.onCancel(); + } else if (this.searchEnabled && this.searchInput) { + const sanitized = data.replace(/ /g, ""); + if (!sanitized) { + return; + } + this.searchInput.handleInput(sanitized); + this.applyFilter(this.searchInput.getValue()); } } private activateItem(): void { - const item = this.items[this.selectedIndex]; + const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex]; if (!item) return; if (item.submenu) { @@ -189,4 +228,20 @@ export class SettingsList implements Component { this.submenuItemIndex = null; } } + + private applyFilter(query: string): void { + this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); + this.selectedIndex = 0; + } + + private addHintLine(lines: string[]): void { + lines.push(""); + lines.push( + this.theme.hint( + this.searchEnabled + ? " Type to search · Enter/Space to change · Esc to cancel" + : " Enter/Space to change · Esc to cancel", + ), + ); + } }