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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+19
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user