Merge pull request #1271 from Whoaa512/feat/session-picker-mvp

feat: session picker MVP - fuzzy search, derived titles, relative time
This commit is contained in:
Peter Steinberger
2026-01-20 16:46:48 +00:00
committed by GitHub
16 changed files with 1216 additions and 126 deletions

View File

@@ -0,0 +1,143 @@
import {
Input,
matchesKey,
type SelectItem,
SelectList,
type SelectListTheme,
getEditorKeybindings,
} from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */
searchText?: string;
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
searchTextLower?: string;
}
export interface FilterableSelectListTheme extends SelectListTheme {
filterLabel: (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 maxVisible: number;
private theme: FilterableSelectListTheme;
private filterText = "";
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
this.allItems = prepareSearchItems(items);
this.maxVisible = maxVisible;
this.theme = theme;
this.input = new Input();
this.selectList = new SelectList(this.allItems, maxVisible, theme);
}
private applyFilter(): void {
const queryLower = this.filterText.toLowerCase();
if (!queryLower.trim()) {
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
return;
}
const filtered = fuzzyFilterLower(this.allItems, queryLower);
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
}
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 {
const allowVimNav = !this.filterText.trim();
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectList.handleInput("\x1b[A");
return;
}
if (
matchesKey(keyData, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectList.handleInput("\x1b[B");
return;
}
// Enter selects
if (matchesKey(keyData, "enter")) {
const selected = this.selectList.getSelectedItem();
if (selected) {
this.onSelect?.(selected);
}
return;
}
// Escape: clear filter or cancel
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) {
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

@@ -0,0 +1,114 @@
/**
* Shared fuzzy filtering utilities for select list components.
*/
/**
* Word boundary characters for matching.
*/
const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
/**
* Check if position is at a word boundary.
*/
export function isWordBoundary(text: string, index: number): boolean {
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
}
/**
* Find index where query matches at a word boundary in text.
* Returns null if no match.
*/
export function findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null;
const textLower = text.toLowerCase();
const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i;
}
}
return null;
}
/**
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
* Returns score (lower = better) or null if no match.
*/
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null;
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
}
if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i;
queryIndex++;
}
}
return queryIndex < queryLower.length ? null : score;
}
/**
* Filter items using pre-lowercased searchTextLower field.
* Supports space-separated tokens (all must match).
*/
export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[],
queryLower: string,
): T[] {
const trimmed = queryLower.trim();
if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = item.searchTextLower ?? "";
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const score = fuzzyMatchLower(token, text);
if (score !== null) {
totalScore += score;
} else {
allMatch = false;
break;
}
}
if (allMatch) results.push({ item, score: totalScore });
}
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
}
/**
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
items: T[],
): (T & { searchTextLower: string })[] {
return items.map((item) => {
const parts: string[] = [];
if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
});
}

View File

@@ -1,6 +1,7 @@
import {
type Component,
fuzzyFilter,
getEditorKeybindings,
Input,
isKeyRelease,
matchesKey,
@@ -9,6 +10,7 @@ import {
truncateToWidth,
} from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js";
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string;
@@ -80,7 +82,7 @@ export class SearchableSelectList implements Component {
continue;
}
// Tier 2: Word-boundary prefix in label (score 100-199)
const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null) {
wordBoundary.push({ item, score: wordBoundaryIndex });
continue;
@@ -111,28 +113,6 @@ export class SearchableSelectList implements Component {
];
}
/**
* Check if query matches at a word boundary in text.
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
*/
private matchesWordBoundary(text: string, query: string): boolean {
return this.findWordBoundaryIndex(text, query) !== null;
}
private findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null;
const maxIndex = text.length - query.length;
if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) {
if (text.startsWith(query, i)) {
if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) {
return i;
}
}
}
return null;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -258,14 +238,24 @@ export class SearchableSelectList implements Component {
handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return;
const allowVimNav = !this.searchInput.getValue().trim();
// Navigation keys
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) {
if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
return;
}
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
if (
matchesKey(keyData, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
return;
@@ -279,7 +269,8 @@ export class SearchableSelectList implements Component {
return;
}
if (matchesKey(keyData, "escape")) {
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) {
this.onCancel();
}

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,8 @@ export type GatewaySessionList = {
lastProvider?: string;
lastTo?: string;
lastAccountId?: string;
derivedTitle?: string;
lastMessagePreview?: string;
}>;
};
@@ -183,6 +185,8 @@ export class GatewayChatClient {
activeMinutes: opts?.activeMinutes,
includeGlobal: opts?.includeGlobal,
includeUnknown: opts?.includeUnknown,
includeDerivedTitles: opts?.includeDerivedTitles,
includeLastMessage: opts?.includeLastMessage,
agentId: opts?.agentId,
});
}

View File

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

View File

@@ -5,9 +5,14 @@ import {
resolveResponseUsageMode,
} from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { formatRelativeTime } from "../utils/time-format.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 +139,37 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
includeDerivedTitles: true,
includeLastMessage: 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);
// Avoid redundant "title (key)" when title matches key
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
// Build description: time + message preview
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
const description =
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
return {
value: session.key,
label,
description,
searchText: [
session.displayName,
session.label,
session.subject,
session.sessionId,
session.key,
session.lastMessagePreview,
]
.filter(Boolean)
.join(" "),
};
});
const selector = createFilterableSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();