diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts new file mode 100644 index 000000000..be64cc9fb --- /dev/null +++ b/src/tui/components/filterable-select-list.ts @@ -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; + } +} diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index f56d24e3b..ba37ff7c9 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -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, diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 1ba09df49..2cd924179 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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, }); } diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 78bb24981..ce071112e 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -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), diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 485296dc3..2451e5d61 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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();