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:
committed by
Peter Steinberger
parent
83d5e30027
commit
95f0befd65
176
src/tui/components/filterable-select-list.ts
Normal file
176
src/tui/components/filterable-select-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user