From a707e2daf98cf2a0a67f623a0dfac2660bae3b67 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 04:24:32 +0200 Subject: [PATCH] feat(tui-components): added searchable filtering and focus retention for settings lists - Added type-to-search filtering across setting labels, IDs, values, and descriptions. - Preserved selected item focus by ID when replacing settings during an active filter. - Displayed search status, empty-filter hints, and no-match messaging while searching. - Updated Escape handling to clear an active query before cancelling the list. - Applied selection and navigation to filtered items to keep behavior consistent under search. --- .../src/modes/components/settings-selector.ts | 6 +- .../settings-selector-memory-refresh.test.ts | 21 ++- packages/tui/CHANGELOG.md | 6 + packages/tui/src/components/settings-list.ts | 176 +++++++++++++++--- packages/tui/test/settings-list.test.ts | 53 ++++++ 5 files changed, 233 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index eb0de0941..7458ea0ee 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -631,8 +631,12 @@ export class SettingsSelectorComponent extends Container { return; } - // Escape at top level cancels + // Escape clears an active settings search before closing the panel. if (matchesAppInterrupt(data) && !this.#currentSubmenu) { + if (this.#currentList?.hasSearchQuery()) { + this.#currentList.clearSearch(); + return; + } this.callbacks.onCancel(); return; } diff --git a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts index 3763fc1ea..8294d4fb7 100644 --- a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts +++ b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts @@ -16,7 +16,7 @@ afterEach(() => { resetSettingsForTest(); }); -function createSelector(): SettingsSelectorComponent { +function createSelector(onCancel: () => void = () => {}): SettingsSelectorComponent { return new SettingsSelectorComponent( { availableThinkingLevels: [], @@ -26,7 +26,7 @@ function createSelector(): SettingsSelectorComponent { }, { onChange: () => {}, - onCancel: () => {}, + onCancel, }, ); } @@ -82,4 +82,21 @@ describe("SettingsSelectorComponent memory tab", () => { expect(after).not.toContain("Hindsight API URL"); expect(after).not.toContain("Hindsight Auto Recall"); }); + + it("clears settings search on Escape before closing the selector", () => { + let cancelCount = 0; + const comp = createSelector(() => { + cancelCount++; + }); + + comp.handleInput("b"); + expect(comp.render(120).join("\n")).toContain("Search: b"); + + comp.handleInput("\x1b"); + expect(cancelCount).toBe(0); + expect(comp.render(120).join("\n")).not.toContain("Search: b"); + + comp.handleInput("\x1b"); + expect(cancelCount).toBe(1); + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c1aac6979..3ae30a7b1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,9 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- `SettingsList` now supports type-to-search filtering with Escape clearing an active query before canceling. ### Changed +- Preserved list selection by item ID when replacing settings so focus stays on the same setting +- Displayed a no matching settings message and search-editing hint when filtering returns no matches +- Expanded settings search matching to include IDs, current values, descriptions, and option values as well as labels - Raised the stdin split-escape flush window from 10ms to 50ms: over laggy links (ssh, slow multiplexers) a CSI sequence split across reads was flushed as literal data, leaking `[` + `A` style fragments into the editor as typed text - Lengthened the OSC 11 appearance poll on terminals without Mode 2031 from 2s to 30s — each poll's query write cleared the user's active text selection, breaking copy every two seconds on Alacritty/Warp/older WezTerm - Rewrote `StdinBuffer.extractCompleteSequences` to index-based scanning: the previous per-iteration `slice` + `Array.from(remaining)[0]` made plain-text bursts O(n²), turning a 100KB non-bracketed paste into a multi-second freeze diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index ef643823b..4b1e61175 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,8 +1,17 @@ +import { fuzzyFilter } from "../fuzzy"; import { getKeybindings } from "../keybindings"; +import { extractPrintableText } from "../keys"; import type { Component } from "../tui"; -import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; import { ScrollView } from "./scroll-view"; +function sanitizeSingleLine(text: string): string { + return replaceTabs(text) + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + export interface SettingItem { /** Unique identifier for this setting */ id: string; @@ -30,16 +39,17 @@ export interface SettingsListTheme { export class SettingsList implements Component { #items: SettingItem[]; + #filteredItems: SettingItem[]; #theme: SettingsListTheme; #selectedIndex = 0; #maxVisible: number; #onChange: (id: string, newValue: string) => void; #onCancel: () => void; + #filterQuery = ""; // Submenu state #submenuComponent: Component | null = null; #submenuItemIndex: number | null = null; - constructor( items: SettingItem[], maxVisible: number, @@ -48,34 +58,120 @@ export class SettingsList implements Component { onCancel: () => void, ) { this.#items = items; + this.#filteredItems = items; this.#maxVisible = maxVisible; this.#theme = theme; this.#onChange = onChange; this.#onCancel = onCancel; } + getSearchQuery(): string { + return this.#filterQuery; + } + + hasSearchQuery(): boolean { + return this.#filterQuery.length > 0; + } + + clearSearch(): void { + if (this.#filterQuery.length === 0) return; + this.#setFilter(""); + } + /** Update an item's currentValue */ updateValue(id: string, newValue: string): void { const item = this.#items.find(i => i.id === id); - if (item) { - item.currentValue = newValue; + if (!item) return; + + item.currentValue = newValue; + if (this.#filterQuery.trim()) { + this.#applyFilter(); + this.#clampSelectedIndex(); } } /** - * Replace the entire items array. Selection is preserved when the prior - * index is still valid, otherwise clamped to the last item (or 0 if the - * list is now empty). An open submenu is left untouched — its lifetime - * is bounded by its own done callback, and `#closeSubmenu` re-clamps the - * restored index against the new list on the way out. + * Replace the entire items array. Selection is preserved by item id when + * the previous selection still survives the active filter, otherwise + * clamped to the last filtered item (or 0 if there are no matches). + * An open submenu is left untouched — its lifetime is bounded by its own + * done callback, and `#closeSubmenu` re-clamps the restored index on exit. */ setItems(items: SettingItem[]): void { + const selectedId = this.#filteredItems[this.#selectedIndex]?.id; this.#items = items; - if (this.#items.length === 0) { - this.#selectedIndex = 0; - } else if (this.#selectedIndex >= this.#items.length) { - this.#selectedIndex = this.#items.length - 1; + this.#applyFilter(); + + if (selectedId) { + const nextIndex = this.#filteredItems.findIndex(item => item.id === selectedId); + if (nextIndex >= 0) { + this.#selectedIndex = nextIndex; + return; + } } + + this.#clampSelectedIndex(); + } + + #setFilter(filter: string): void { + this.#filterQuery = filter; + this.#applyFilter(); + this.#selectedIndex = 0; + } + + #applyFilter(): void { + this.#filteredItems = this.#filterQuery.trim() + ? fuzzyFilter([...this.#items], this.#filterQuery, item => this.#getFilterText(item)) + : this.#items; + } + + #clampSelectedIndex(): void { + if (this.#filteredItems.length === 0) { + this.#selectedIndex = 0; + return; + } + this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#filteredItems.length - 1)); + } + + #getFilterText(item: SettingItem): string { + let text = `${item.label} ${item.id} ${item.currentValue}`; + if (item.description) { + text += ` ${item.description}`; + } + if (item.values) { + text += ` ${item.values.join(" ")}`; + } + return sanitizeSingleLine(text); + } + + #renderSearchStatus(width: number): string { + const query = sanitizeSingleLine(this.#filterQuery); + const statusText = query ? ` Search: ${query}` : " Type to search"; + return this.#theme.hint(truncateToWidth(statusText, width, Ellipsis.Omit)); + } + + #shouldRenderSearchStatus(): boolean { + return this.#items.length > this.#maxVisible || this.#filterQuery.length > 0; + } + + #handleSearchInput(data: string): boolean { + if (this.#items.length === 0) return false; + + const kb = getKeybindings(); + if (kb.matches(data, "tui.editor.deleteCharBackward")) { + if (this.#filterQuery.length === 0) return false; + const chars = [...this.#filterQuery]; + chars.pop(); + this.#setFilter(chars.join("")); + return true; + } + + const printableText = extractPrintableText(data); + if (printableText === undefined) return false; + if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false; + + this.#setFilter(this.#filterQuery + printableText); + return true; } invalidate(): void { @@ -115,22 +211,32 @@ export class SettingsList implements Component { return lines; } - const viewportHeight = Math.min(this.#maxVisible, this.#items.length); + if (this.#filteredItems.length === 0) { + if (this.#shouldRenderSearchStatus()) { + lines.push(this.#renderSearchStatus(width)); + } + lines.push(this.#theme.hint(" No matching settings")); + lines.push(""); + lines.push(truncateToWidth(this.#theme.hint(" Backspace to edit search · Esc to cancel"), width)); + return lines; + } + + const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length); const startIndex = Math.max( 0, - Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#items.length - viewportHeight), + Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight), ); - const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label)))); - const itemRowsOverflow = this.#items.length > viewportHeight; + const maxLabelWidth = Math.min(30, Math.max(...this.#filteredItems.map(item => visibleWidth(item.label)))); + const itemRowsOverflow = this.#filteredItems.length > viewportHeight; const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0)); - const visibleItems = this.#items.slice(startIndex, startIndex + viewportHeight); + const visibleItems = this.#filteredItems.slice(startIndex, startIndex + viewportHeight); const itemRows = visibleItems.map((item, index) => this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth), ); const scrollView = new ScrollView(itemRows, { height: viewportHeight, scrollbar: "auto", - totalRows: this.#items.length, + totalRows: this.#filteredItems.length, theme: { track: text => this.#theme.hint(text), thumb: text => this.#theme.label(text, true, false), @@ -140,7 +246,7 @@ export class SettingsList implements Component { lines.push(...scrollView.render(width)); // Add description for selected item - const selectedItem = this.#items[this.#selectedIndex]; + const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem?.description) { lines.push(""); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); @@ -149,9 +255,13 @@ export class SettingsList implements Component { } } + if (this.#shouldRenderSearchStatus()) { + lines.push(this.#renderSearchStatus(width)); + } + // Add hint lines.push(""); - lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width)); + lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Type to search · Esc to cancel"), width)); return lines; } @@ -166,19 +276,32 @@ export class SettingsList implements Component { // Main list input handling const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { + if (this.#filterQuery.length > 0) { + this.clearSearch(); + return; + } + this.#onCancel(); + return; + } + + if (this.#handleSearchInput(data)) { + return; + } + + if (this.#filteredItems.length === 0) return; + if (kb.matches(data, "tui.select.up")) { - this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1; + this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1; } else if (kb.matches(data, "tui.select.down")) { - this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1; + this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1; } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") { this.#activateItem(); - } else if (kb.matches(data, "tui.select.cancel")) { - this.#onCancel(); } } #activateItem(): void { - const item = this.#items[this.#selectedIndex]; + const item = this.#filteredItems[this.#selectedIndex]; if (!item) return; if (item.submenu) { @@ -207,6 +330,7 @@ export class SettingsList implements Component { if (this.#submenuItemIndex !== null) { this.#selectedIndex = this.#submenuItemIndex; this.#submenuItemIndex = null; + this.#clampSelectedIndex(); } } } diff --git a/packages/tui/test/settings-list.test.ts b/packages/tui/test/settings-list.test.ts index f6273596b..c4f597f34 100644 --- a/packages/tui/test/settings-list.test.ts +++ b/packages/tui/test/settings-list.test.ts @@ -107,4 +107,57 @@ describe("SettingsList", () => { expect(list.render(16)[0]).toBe("→ Mode 123456"); }); + + it("filters settings with printable search text", () => { + const list = new SettingsList( + [ + { id: "mode", label: "Mode", currentValue: "off", values: ["off", "on"] }, + { id: "theme.dark", label: "Theme", currentValue: "dark", values: ["dark", "light"] }, + { + id: "browser.path", + label: "Browser Path", + description: "Executable used for browser launches", + currentValue: "", + }, + ], + 5, + testTheme, + () => {}, + () => {}, + ); + + list.handleInput("b"); + + const output = list.render(80).join("\n"); + expect(output).toContain("Search: b"); + expect(output).toContain("Browser Path"); + expect(output).not.toContain("Theme"); + expect(output).not.toContain("Mode"); + }); + + it("clears active search on Escape before canceling", () => { + let cancelCount = 0; + const list = new SettingsList( + [ + { id: "mode", label: "Mode", currentValue: "off", values: ["off", "on"] }, + { id: "browser.path", label: "Browser Path", currentValue: "" }, + ], + 5, + testTheme, + () => {}, + () => { + cancelCount++; + }, + ); + + list.handleInput("b"); + expect(list.hasSearchQuery()).toBe(true); + + list.handleInput("\x1b"); + expect(list.hasSearchQuery()).toBe(false); + expect(cancelCount).toBe(0); + + list.handleInput("\x1b"); + expect(cancelCount).toBe(1); + }); });