feat: add fuzzy filter to TUI session picker

Users can now type to filter sessions in real-time:
- FilterableSelectList component wraps pi-tui's fuzzyFilter
- Matches against displayName, label, subject, sessionId
- j/k navigation, Enter selects, Escape clears filter then cancels
- Uses derivedTitle from previous commit for better display

Refs #1161
This commit is contained in:
CJ Winslow
2026-01-18 02:58:24 -08:00
committed by Peter Steinberger
parent 83d5e30027
commit 95f0befd65
5 changed files with 223 additions and 10 deletions

View File

@@ -0,0 +1,176 @@
import {
Input,
type SelectItem,
SelectList,
type SelectListTheme,
fuzzyFilter,
} from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk";
export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */
searchText?: string;
}
export interface FilterableSelectListTheme extends SelectListTheme {
filterLabel: (text: string) => string;
filterInput: (text: string) => string;
}
/**
* Combines text input filtering with a select list.
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
*/
export class FilterableSelectList implements Component {
private input: Input;
private selectList: SelectList;
private allItems: FilterableSelectItem[];
private theme: FilterableSelectListTheme;
private filterText = "";
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
constructor(
items: FilterableSelectItem[],
maxVisible: number,
theme: FilterableSelectListTheme,
) {
this.allItems = items;
this.theme = theme;
this.input = new Input();
this.selectList = new SelectList(items, maxVisible, theme);
// Wire up input to filter the list
this.input.onSubmit = () => {
const selected = this.selectList.getSelectedItem();
if (selected) {
this.onSelect?.(selected);
}
};
this.input.onEscape = () => {
if (this.filterText) {
// First escape clears filter
this.filterText = "";
this.input.setValue("");
this.selectList.setFilter("");
this.selectList.setSelectedIndex(0);
} else {
// Second escape cancels
this.onCancel?.();
}
};
}
private getSearchText(item: FilterableSelectItem): string {
const parts = [item.label];
if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return parts.join(" ");
}
private applyFilter(): void {
const query = this.filterText.toLowerCase().trim();
if (!query) {
// Reset to all items
this.selectList = new SelectList(
this.allItems,
this.selectList["maxVisible"],
this.theme,
);
this.selectList.onSelect = this.onSelect;
this.selectList.onCancel = this.onCancel;
return;
}
// Use fuzzy filter from pi-tui
const filtered = fuzzyFilter(this.allItems, query, (item) =>
this.getSearchText(item),
);
this.selectList = new SelectList(
filtered,
this.selectList["maxVisible"],
this.theme,
);
this.selectList.onSelect = this.onSelect;
this.selectList.onCancel = this.onCancel;
}
invalidate(): void {
this.input.invalidate();
this.selectList.invalidate();
}
render(width: number): string[] {
const lines: string[] = [];
// Filter input row
const filterLabel = this.theme.filterLabel("Filter: ");
const inputLines = this.input.render(width - 8);
const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText);
// Separator
lines.push(chalk.dim("─".repeat(width)));
// Select list
const listLines = this.selectList.render(width);
lines.push(...listLines);
return lines;
}
handleInput(keyData: string): void {
// Navigation keys go to select list
if (keyData === "\x1b[A" || keyData === "\x1b[B" || keyData === "k" || keyData === "j") {
// Map vim keys to arrows for selectList
if (keyData === "k") keyData = "\x1b[A";
if (keyData === "j") keyData = "\x1b[B";
this.selectList.handleInput(keyData);
return;
}
// Enter selects
if (keyData === "\r" || keyData === "\n") {
const selected = this.selectList.getSelectedItem();
if (selected) {
this.onSelect?.(selected);
}
return;
}
// Escape: clear filter or cancel
if (keyData === "\x1b" || keyData === "\x1b\x1b") {
if (this.filterText) {
this.filterText = "";
this.input.setValue("");
this.applyFilter();
} else {
this.onCancel?.();
}
return;
}
// All other input goes to filter
const prevValue = this.input.getValue();
this.input.handleInput(keyData);
const newValue = this.input.getValue();
if (newValue !== prevValue) {
this.filterText = newValue;
this.applyFilter();
}
}
getSelectedItem(): SelectItem | null {
return this.selectList.getSelectedItem();
}
getFilterText(): string {
return this.filterText;
}
}

View File

@@ -1,5 +1,14 @@
import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js";
import {
filterableSelectListTheme,
searchableSelectListTheme,
selectListTheme,
settingsListTheme,
} from "../theme/theme.js";
import {
FilterableSelectList,
type FilterableSelectItem,
} from "./filterable-select-list.js";
import { SearchableSelectList } from "./searchable-select-list.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) {
@@ -10,6 +19,10 @@ export function createSearchableSelectList(items: SelectItem[], maxVisible = 7)
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
}
export function createFilterableSelectList(items: FilterableSelectItem[], maxVisible = 7) {
return new FilterableSelectList(items, maxVisible, filterableSelectListTheme);
}
export function createSettingsList(
items: SettingItem[],
onChange: (id: string, value: string) => void,

View File

@@ -64,6 +64,7 @@ export type GatewaySessionList = {
lastProvider?: string;
lastTo?: string;
lastAccountId?: string;
derivedTitle?: string;
}>;
};
@@ -183,6 +184,7 @@ export class GatewayChatClient {
activeMinutes: opts?.activeMinutes,
includeGlobal: opts?.includeGlobal,
includeUnknown: opts?.includeUnknown,
includeDerivedTitles: opts?.includeDerivedTitles,
agentId: opts?.agentId,
});
}

View File

@@ -106,6 +106,12 @@ export const selectListTheme: SelectListTheme = {
noMatch: (text) => fg(palette.dim)(text),
};
export const filterableSelectListTheme = {
...selectListTheme,
filterLabel: (text: string) => fg(palette.dim)(text),
filterInput: (text: string) => fg(palette.text)(text),
};
export const settingsListTheme: SettingsListTheme = {
label: (text, selected) =>
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),

View File

@@ -7,7 +7,11 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
import {
createFilterableSelectList,
createSearchableSelectList,
createSettingsList,
} from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
@@ -134,16 +138,28 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
includeDerivedTitles: true,
agentId: state.currentAgentId,
});
const items = result.sessions.map((session) => ({
value: session.key,
label: session.displayName
? `${session.displayName} (${formatSessionKey(session.key)})`
: formatSessionKey(session.key),
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
}));
const selector = createSearchableSelectList(items, 9);
const items = result.sessions.map((session) => {
const title = session.derivedTitle ?? session.displayName;
const formattedKey = formatSessionKey(session.key);
return {
value: session.key,
label: title ? `${title} (${formattedKey})` : formattedKey,
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
searchText: [
session.displayName,
session.label,
session.subject,
session.sessionId,
session.key,
]
.filter(Boolean)
.join(" "),
};
});
const selector = createFilterableSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();