TUI: optimize fuzzy filtering and consolidate time formatting

- Extract formatRelativeTime to shared utility for reuse across components
- Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke)
- Implement custom fuzzy matching with space-separated token support and word boundary scoring
- Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n)
- Fix searchable-select-list to support vim keybindings consistently
- Fix system-prompt runtimeInfo null check with nullish coalescing operator
This commit is contained in:
CJ Winslow
2026-01-18 23:55:16 -08:00
committed by Peter Steinberger
parent 1d9d5b30ce
commit a28c271488
5 changed files with 1409 additions and 1373 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,89 @@
import { import {
Input, Input,
type SelectItem, matchesKey,
SelectList, type SelectItem,
type SelectListTheme, SelectList,
fuzzyFilter, type SelectListTheme,
getEditorKeybindings, getEditorKeybindings,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
/**
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
* Returns score (lower = better) or null if no match.
*/
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 isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
}
if (isWordBoundary) score -= 10;
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
return queryIndex < queryLower.length ? null : score;
}
/**
* Filter items using pre-lowercased searchTextLower field.
* Supports space-separated tokens (all must match).
*/
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);
}
export interface FilterableSelectItem extends SelectItem { export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */ /** Additional searchable fields beyond label */
searchText?: string; searchText?: string;
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
searchTextLower?: string;
} }
export interface FilterableSelectListTheme extends SelectListTheme { export interface FilterableSelectListTheme extends SelectListTheme {
filterLabel: (text: string) => string; filterLabel: (text: string) => string;
} }
/** /**
@@ -23,121 +91,116 @@ export interface FilterableSelectListTheme extends SelectListTheme {
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel. * User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
*/ */
export class FilterableSelectList implements Component { export class FilterableSelectList implements Component {
private input: Input; private input: Input;
private selectList: SelectList; private selectList: SelectList;
private allItems: FilterableSelectItem[]; private allItems: FilterableSelectItem[];
private maxVisible: number; private maxVisible: number;
private theme: FilterableSelectListTheme; private theme: FilterableSelectListTheme;
private filterText = ""; private filterText = "";
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
constructor( constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
items: FilterableSelectItem[], // Pre-compute searchTextLower for each item once
maxVisible: number, this.allItems = items.map((item) => {
theme: FilterableSelectListTheme, if (item.searchTextLower) return item;
) { const parts = [item.label];
this.allItems = items; if (item.description) parts.push(item.description);
this.maxVisible = maxVisible; if (item.searchText) parts.push(item.searchText);
this.theme = theme; return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
});
this.maxVisible = maxVisible;
this.theme = theme;
this.input = new Input(); this.input = new Input();
this.selectList = new SelectList(items, maxVisible, theme); this.selectList = new SelectList(this.allItems, maxVisible, theme);
} }
private getSearchText(item: FilterableSelectItem): string { private applyFilter(): void {
const parts = [item.label]; const queryLower = this.filterText.toLowerCase();
if (item.description) parts.push(item.description); if (!queryLower.trim()) {
if (item.searchText) parts.push(item.searchText); this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
return parts.join(" "); return;
} }
const filtered = fuzzyFilterLower(this.allItems, queryLower);
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
}
private applyFilter(): void { invalidate(): void {
const query = this.filterText.toLowerCase().trim(); this.input.invalidate();
if (!query) { this.selectList.invalidate();
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); }
return;
}
const filtered = fuzzyFilter(this.allItems, query, (item) => render(width: number): string[] {
this.getSearchText(item), const lines: string[] = [];
);
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
}
invalidate(): void { // Filter input row
this.input.invalidate(); const filterLabel = this.theme.filterLabel("Filter: ");
this.selectList.invalidate(); const inputLines = this.input.render(width - 8);
} const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText);
render(width: number): string[] { // Separator
const lines: string[] = []; lines.push(chalk.dim("─".repeat(width)));
// Filter input row // Select list
const filterLabel = this.theme.filterLabel("Filter: "); const listLines = this.selectList.render(width);
const inputLines = this.input.render(width - 8); lines.push(...listLines);
const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText);
// Separator return lines;
lines.push(chalk.dim("─".repeat(width))); }
// Select list handleInput(keyData: string): void {
const listLines = this.selectList.render(width); // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
lines.push(...listLines); if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
this.selectList.handleInput("\x1b[A");
return;
}
return lines; if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
} this.selectList.handleInput("\x1b[B");
return;
}
handleInput(keyData: string): void { // Enter selects
// Navigation keys go to select list if (matchesKey(keyData, "enter")) {
if (keyData === "\x1b[A" || keyData === "\x1b[B" || keyData === "k" || keyData === "j") { const selected = this.selectList.getSelectedItem();
// Map vim keys to arrows for selectList if (selected) {
if (keyData === "k") keyData = "\x1b[A"; this.onSelect?.(selected);
if (keyData === "j") keyData = "\x1b[B"; }
this.selectList.handleInput(keyData); return;
return; }
}
// Enter selects // Escape: clear filter or cancel
if (keyData === "\r" || keyData === "\n") { const kb = getEditorKeybindings();
const selected = this.selectList.getSelectedItem(); if (kb.matches(keyData, "selectCancel")) {
if (selected) { if (this.filterText) {
this.onSelect?.(selected); this.filterText = "";
} this.input.setValue("");
return; this.applyFilter();
} } else {
this.onCancel?.();
}
return;
}
// Escape: clear filter or cancel // All other input goes to filter
const kb = getEditorKeybindings(); const prevValue = this.input.getValue();
if (kb.matches(keyData, "selectCancel")) { this.input.handleInput(keyData);
if (this.filterText) { const newValue = this.input.getValue();
this.filterText = "";
this.input.setValue("");
this.applyFilter();
} else {
this.onCancel?.();
}
return;
}
// All other input goes to filter if (newValue !== prevValue) {
const prevValue = this.input.getValue(); this.filterText = newValue;
this.input.handleInput(keyData); this.applyFilter();
const newValue = this.input.getValue(); }
}
if (newValue !== prevValue) { getSelectedItem(): SelectItem | null {
this.filterText = newValue; return this.selectList.getSelectedItem();
this.applyFilter(); }
}
}
getSelectedItem(): SelectItem | null { getFilterText(): string {
return this.selectList.getSelectedItem(); return this.filterText;
} }
getFilterText(): string {
return this.filterText;
}
} }

View File

@@ -1,309 +1,303 @@
import { import {
type Component, type Component,
fuzzyFilter, fuzzyFilter,
Input, getEditorKeybindings,
isKeyRelease, Input,
matchesKey, isKeyRelease,
type SelectItem, matchesKey,
type SelectListTheme, type SelectItem,
truncateToWidth, type SelectListTheme,
truncateToWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js"; import { visibleWidth } from "../../terminal/ansi.js";
export interface SearchableSelectListTheme extends SelectListTheme { export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string; searchPrompt: (text: string) => string;
searchInput: (text: string) => string; searchInput: (text: string) => string;
matchHighlight: (text: string) => string; matchHighlight: (text: string) => string;
} }
/** /**
* A select list with a search input at the top for fuzzy filtering. * A select list with a search input at the top for fuzzy filtering.
*/ */
export class SearchableSelectList implements Component { export class SearchableSelectList implements Component {
private items: SelectItem[]; private items: SelectItem[];
private filteredItems: SelectItem[]; private filteredItems: SelectItem[];
private selectedIndex = 0; private selectedIndex = 0;
private maxVisible: number; private maxVisible: number;
private theme: SearchableSelectListTheme; private theme: SearchableSelectListTheme;
private searchInput: Input; private searchInput: Input;
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
onSelectionChange?: (item: SelectItem) => void; onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
this.items = items; this.items = items;
this.filteredItems = items; this.filteredItems = items;
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme; this.theme = theme;
this.searchInput = new Input(); this.searchInput = new Input();
} }
private updateFilter() { private updateFilter() {
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
if (!query) { if (!query) {
this.filteredItems = this.items; this.filteredItems = this.items;
} else { } else {
this.filteredItems = this.smartFilter(query); this.filteredItems = this.smartFilter(query);
} }
// Reset selection when filter changes // Reset selection when filter changes
this.selectedIndex = 0; this.selectedIndex = 0;
this.notifySelectionChange(); this.notifySelectionChange();
} }
/** /**
* Smart filtering that prioritizes: * Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority) * 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label * 2. Word-boundary prefix match in label
* 3. Exact substring match in description * 3. Exact substring match in description
* 4. Fuzzy match (lowest priority) * 4. Fuzzy match (lowest priority)
*/ */
private smartFilter(query: string): SelectItem[] { private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number }; type ScoredItem = { item: SelectItem; score: number };
const exactLabel: ScoredItem[] = []; const exactLabel: ScoredItem[] = [];
const wordBoundary: ScoredItem[] = []; const wordBoundary: ScoredItem[] = [];
const descriptionMatches: ScoredItem[] = []; const descriptionMatches: ScoredItem[] = [];
const fuzzyCandidates: SelectItem[] = []; const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) { for (const item of this.items) {
const label = item.label.toLowerCase(); const label = item.label.toLowerCase();
const desc = (item.description ?? "").toLowerCase(); const desc = (item.description ?? "").toLowerCase();
// Tier 1: Exact substring in label (score 0-99) // Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q); const labelIndex = label.indexOf(q);
if (labelIndex !== -1) { if (labelIndex !== -1) {
// Earlier match = better score // Earlier match = better score
exactLabel.push({ item, score: labelIndex }); exactLabel.push({ item, score: labelIndex });
continue; continue;
} }
// Tier 2: Word-boundary prefix in label (score 100-199) // Tier 2: Word-boundary prefix in label (score 100-199)
const wordBoundaryIndex = this.findWordBoundaryIndex(label, q); const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null) { if (wordBoundaryIndex !== null) {
wordBoundary.push({ item, score: wordBoundaryIndex }); wordBoundary.push({ item, score: wordBoundaryIndex });
continue; continue;
} }
// Tier 3: Exact substring in description (score 200-299) // Tier 3: Exact substring in description (score 200-299)
const descIndex = desc.indexOf(q); const descIndex = desc.indexOf(q);
if (descIndex !== -1) { if (descIndex !== -1) {
descriptionMatches.push({ item, score: descIndex }); descriptionMatches.push({ item, score: descIndex });
continue; continue;
} }
// Tier 4: Fuzzy match (score 300+) // Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item); fuzzyCandidates.push(item);
} }
exactLabel.sort(this.compareByScore); exactLabel.sort(this.compareByScore);
wordBoundary.sort(this.compareByScore); wordBoundary.sort(this.compareByScore);
descriptionMatches.sort(this.compareByScore); descriptionMatches.sort(this.compareByScore);
const fuzzyMatches = fuzzyFilter( const fuzzyMatches = fuzzyFilter(
fuzzyCandidates, fuzzyCandidates,
query, query,
(i) => `${i.label} ${i.description ?? ""}`, (i) => `${i.label} ${i.description ?? ""}`,
); );
return [ return [
...exactLabel.map((s) => s.item), ...exactLabel.map((s) => s.item),
...wordBoundary.map((s) => s.item), ...wordBoundary.map((s) => s.item),
...descriptionMatches.map((s) => s.item), ...descriptionMatches.map((s) => s.item),
...fuzzyMatches, ...fuzzyMatches,
]; ];
} }
/** private findWordBoundaryIndex(text: string, query: string): number | null {
* Check if query matches at a word boundary in text. if (!query) return null;
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. const maxIndex = text.length - query.length;
*/ if (maxIndex < 0) return null;
private matchesWordBoundary(text: string, query: string): boolean { for (let i = 0; i <= maxIndex; i++) {
return this.findWordBoundaryIndex(text, query) !== null; if (text.startsWith(query, i)) {
} if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) {
return i;
}
}
}
return null;
}
private findWordBoundaryIndex(text: string, query: string): number | null { private escapeRegex(str: string): string {
if (!query) return null; return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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 { private compareByScore = (
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); a: { item: SelectItem; score: number },
} b: { item: SelectItem; score: number },
) => {
if (a.score !== b.score) return a.score - b.score;
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
};
private compareByScore = ( private getItemLabel(item: SelectItem): string {
a: { item: SelectItem; score: number }, return item.label || item.value;
b: { item: SelectItem; score: number }, }
) => {
if (a.score !== b.score) return a.score - b.score;
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
};
private getItemLabel(item: SelectItem): string { private highlightMatch(text: string, query: string): string {
return item.label || item.value; const tokens = query
} .trim()
.split(/\s+/)
.map((token) => token.toLowerCase())
.filter((token) => token.length > 0);
if (tokens.length === 0) return text;
private highlightMatch(text: string, query: string): string { const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
const tokens = query let result = text;
.trim() for (const token of uniqueTokens) {
.split(/\s+/) const regex = new RegExp(this.escapeRegex(token), "gi");
.map((token) => token.toLowerCase()) result = result.replace(regex, (match) => this.theme.matchHighlight(match));
.filter((token) => token.length > 0); }
if (tokens.length === 0) return text; return result;
}
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); setSelectedIndex(index: number) {
let result = text; this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
for (const token of uniqueTokens) { }
const regex = new RegExp(this.escapeRegex(token), "gi");
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
}
return result;
}
setSelectedIndex(index: number) { invalidate() {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); this.searchInput.invalidate();
} }
invalidate() { render(width: number): string[] {
this.searchInput.invalidate(); const lines: string[] = [];
}
render(width: number): string[] { // Search input line
const lines: string[] = []; const promptText = "search: ";
const prompt = this.theme.searchPrompt(promptText);
const inputWidth = Math.max(1, width - visibleWidth(prompt));
const inputLines = this.searchInput.render(inputWidth);
const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer
// Search input line const query = this.searchInput.getValue().trim();
const promptText = "search: ";
const prompt = this.theme.searchPrompt(promptText);
const inputWidth = Math.max(1, width - visibleWidth(prompt));
const inputLines = this.searchInput.render(inputWidth);
const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer
const query = this.searchInput.getValue().trim(); // If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matches"));
return lines;
}
// If no items match filter, show message // Calculate visible range with scrolling
if (this.filteredItems.length === 0) { const startIndex = Math.max(
lines.push(this.theme.noMatch(" No matches")); 0,
return lines; Math.min(
} this.selectedIndex - Math.floor(this.maxVisible / 2),
this.filteredItems.length - this.maxVisible,
),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Calculate visible range with scrolling // Render visible items
const startIndex = Math.max( for (let i = startIndex; i < endIndex; i++) {
0, const item = this.filteredItems[i];
Math.min( if (!item) continue;
this.selectedIndex - Math.floor(this.maxVisible / 2), const isSelected = i === this.selectedIndex;
this.filteredItems.length - this.maxVisible, lines.push(this.renderItemLine(item, isSelected, width, query));
), }
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items // Show scroll indicator if needed
for (let i = startIndex; i < endIndex; i++) { if (this.filteredItems.length > this.maxVisible) {
const item = this.filteredItems[i]; const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
if (!item) continue; lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
const isSelected = i === this.selectedIndex; }
lines.push(this.renderItemLine(item, isSelected, width, query));
}
// Show scroll indicator if needed return lines;
if (this.filteredItems.length > this.maxVisible) { }
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
}
return lines; private renderItemLine(
} item: SelectItem,
isSelected: boolean,
width: number,
query: string,
): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = prefix.length;
const displayValue = this.getItemLabel(item);
private renderItemLine( if (item.description && width > 40) {
item: SelectItem, const maxValueWidth = Math.min(30, width - prefixWidth - 4);
isSelected: boolean, const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
width: number, const valueText = this.highlightMatch(truncatedValue, query);
query: string, const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
): string { const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
const prefix = isSelected ? "→ " : " "; const remainingWidth = width - descriptionStart - 2;
const prefixWidth = prefix.length; if (remainingWidth > 10) {
const displayValue = this.getItemLabel(item); const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
const descText = isSelected
? this.highlightMatch(truncatedDesc, query)
: this.highlightMatch(this.theme.description(truncatedDesc), query);
const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this.theme.selectedText(line) : line;
}
}
if (item.description && width > 40) { const maxWidth = width - prefixWidth - 2;
const maxValueWidth = Math.min(30, width - prefixWidth - 4); const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const valueText = this.highlightMatch(truncatedValue, query);
const valueText = this.highlightMatch(truncatedValue, query); const line = `${prefix}${valueText}`;
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); return isSelected ? this.theme.selectedText(line) : line;
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; }
const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
const descText = isSelected
? this.highlightMatch(truncatedDesc, query)
: this.highlightMatch(this.theme.description(truncatedDesc), query);
const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this.theme.selectedText(line) : line;
}
}
const maxWidth = width - prefixWidth - 2; handleInput(keyData: string): void {
const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); if (isKeyRelease(keyData)) return;
const valueText = this.highlightMatch(truncatedValue, query);
const line = `${prefix}${valueText}`;
return isSelected ? this.theme.selectedText(line) : line;
}
handleInput(keyData: string): void { // Navigation keys
if (isKeyRelease(keyData)) return; if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
return;
}
// Navigation keys if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) { this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.notifySelectionChange();
this.notifySelectionChange(); return;
return; }
}
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) { if (matchesKey(keyData, "enter")) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); const item = this.filteredItems[this.selectedIndex];
this.notifySelectionChange(); if (item && this.onSelect) {
return; this.onSelect(item);
} }
return;
}
if (matchesKey(keyData, "enter")) { const kb = getEditorKeybindings();
const item = this.filteredItems[this.selectedIndex]; if (kb.matches(keyData, "selectCancel")) {
if (item && this.onSelect) { if (this.onCancel) {
this.onSelect(item); this.onCancel();
} }
return; return;
} }
if (matchesKey(keyData, "escape")) { // Pass other keys to search input
if (this.onCancel) { const prevValue = this.searchInput.getValue();
this.onCancel(); this.searchInput.handleInput(keyData);
} const newValue = this.searchInput.getValue();
return;
}
// Pass other keys to search input if (prevValue !== newValue) {
const prevValue = this.searchInput.getValue(); this.updateFilter();
this.searchInput.handleInput(keyData); }
const newValue = this.searchInput.getValue(); }
if (prevValue !== newValue) { private notifySelectionChange() {
this.updateFilter(); const item = this.filteredItems[this.selectedIndex];
} if (item && this.onSelectionChange) {
} this.onSelectionChange(item);
}
}
private notifySelectionChange() { getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex]; return this.filteredItems[this.selectedIndex] ?? null;
if (item && this.onSelectionChange) { }
this.onSelectionChange(item);
}
}
getSelectedItem(): SelectItem | null {
return this.filteredItems[this.selectedIndex] ?? null;
}
} }

View File

@@ -1,478 +1,462 @@
import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Component, TUI } from "@mariozechner/pi-tui";
import { import {
formatThinkingLevels, formatThinkingLevels,
normalizeUsageDisplay, normalizeUsageDisplay,
resolveResponseUsageMode, resolveResponseUsageMode,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { formatRelativeTime } from "../utils/time-format.js";
import { helpText, parseCommand } from "./commands.js"; import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js"; import type { ChatLog } from "./components/chat-log.js";
import { import {
createFilterableSelectList, createFilterableSelectList,
createSearchableSelectList, createSearchableSelectList,
createSettingsList, createSettingsList,
} from "./components/selectors.js"; } from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js"; import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js"; import { formatStatusSummary } from "./tui-status-summary.js";
import type { import type {
AgentSummary, AgentSummary,
GatewayStatusSummary, GatewayStatusSummary,
TuiOptions, TuiOptions,
TuiStateAccess, TuiStateAccess,
} from "./tui-types.js"; } from "./tui-types.js";
type CommandHandlerContext = { type CommandHandlerContext = {
client: GatewayChatClient; client: GatewayChatClient;
chatLog: ChatLog; chatLog: ChatLog;
tui: TUI; tui: TUI;
opts: TuiOptions; opts: TuiOptions;
state: TuiStateAccess; state: TuiStateAccess;
deliverDefault: boolean; deliverDefault: boolean;
openOverlay: (component: Component) => void; openOverlay: (component: Component) => void;
closeOverlay: () => void; closeOverlay: () => void;
refreshSessionInfo: () => Promise<void>; refreshSessionInfo: () => Promise<void>;
loadHistory: () => Promise<void>; loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>; setSession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>; refreshAgents: () => Promise<void>;
abortActive: () => Promise<void>; abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void; setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string; formatSessionKey: (key: string) => string;
}; };
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function createCommandHandlers(context: CommandHandlerContext) { export function createCommandHandlers(context: CommandHandlerContext) {
const { const {
client, client,
chatLog, chatLog,
tui, tui,
opts, opts,
state, state,
deliverDefault, deliverDefault,
openOverlay, openOverlay,
closeOverlay, closeOverlay,
refreshSessionInfo, refreshSessionInfo,
loadHistory, loadHistory,
setSession, setSession,
refreshAgents, refreshAgents,
abortActive, abortActive,
setActivityStatus, setActivityStatus,
formatSessionKey, formatSessionKey,
} = context; } = context;
const setAgent = async (id: string) => { const setAgent = async (id: string) => {
state.currentAgentId = normalizeAgentId(id); state.currentAgentId = normalizeAgentId(id);
await setSession(""); await setSession("");
}; };
const openModelSelector = async () => { const openModelSelector = async () => {
try { try {
const models = await client.listModels(); const models = await client.listModels();
if (models.length === 0) { if (models.length === 0) {
chatLog.addSystem("no models available"); chatLog.addSystem("no models available");
tui.requestRender(); tui.requestRender();
return; return;
} }
const items = models.map((model) => ({ const items = models.map((model) => ({
value: `${model.provider}/${model.id}`, value: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.id}`, label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "", description: model.name && model.name !== model.id ? model.name : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { selector.onSelect = (item) => {
void (async () => { void (async () => {
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: item.value, model: item.value,
}); });
chatLog.addSystem(`model set to ${item.value}`); chatLog.addSystem(`model set to ${item.value}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
} }
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
})(); })();
}; };
selector.onCancel = () => { selector.onCancel = () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}; };
openOverlay(selector); openOverlay(selector);
tui.requestRender(); tui.requestRender();
} catch (err) { } catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`); chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender(); tui.requestRender();
} }
}; };
const openAgentSelector = async () => { const openAgentSelector = async () => {
await refreshAgents(); await refreshAgents();
if (state.agents.length === 0) { if (state.agents.length === 0) {
chatLog.addSystem("no agents found"); chatLog.addSystem("no agents found");
tui.requestRender(); tui.requestRender();
return; return;
} }
const items = state.agents.map((agent: AgentSummary) => ({ const items = state.agents.map((agent: AgentSummary) => ({
value: agent.id, value: agent.id,
label: agent.name ? `${agent.id} (${agent.name})` : agent.id, label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === state.agentDefaultId ? "default" : "", description: agent.id === state.agentDefaultId ? "default" : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { selector.onSelect = (item) => {
void (async () => { void (async () => {
closeOverlay(); closeOverlay();
await setAgent(item.value); await setAgent(item.value);
tui.requestRender(); tui.requestRender();
})(); })();
}; };
selector.onCancel = () => { selector.onCancel = () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}; };
openOverlay(selector); openOverlay(selector);
tui.requestRender(); tui.requestRender();
}; };
const openSessionSelector = async () => { const openSessionSelector = async () => {
try { try {
const result = await client.listSessions({ const result = await client.listSessions({
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
includeDerivedTitles: true, includeDerivedTitles: true,
includeLastMessage: true, includeLastMessage: true,
agentId: state.currentAgentId, agentId: state.currentAgentId,
}); });
const items = result.sessions.map((session) => { const items = result.sessions.map((session) => {
const title = session.derivedTitle ?? session.displayName; const title = session.derivedTitle ?? session.displayName;
const formattedKey = formatSessionKey(session.key); const formattedKey = formatSessionKey(session.key);
// Avoid redundant "title (key)" when title matches key // Avoid redundant "title (key)" when title matches key
const label = const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; // Build description: time + message preview
// Build description: time + message preview const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); const description = preview ? `${timePart} · ${preview}` : timePart;
const description = preview ? `${timePart} · ${preview}` : timePart; return {
return { value: session.key,
value: session.key, label,
label, description,
description, searchText: [
searchText: [ session.displayName,
session.displayName, session.label,
session.label, session.subject,
session.subject, session.sessionId,
session.sessionId, session.key,
session.key, session.lastMessagePreview,
session.lastMessagePreview, ]
] .filter(Boolean)
.filter(Boolean) .join(" "),
.join(" "), };
}; });
}); const selector = createFilterableSelectList(items, 9);
const selector = createFilterableSelectList(items, 9); selector.onSelect = (item) => {
selector.onSelect = (item) => { void (async () => {
void (async () => { closeOverlay();
closeOverlay(); await setSession(item.value);
await setSession(item.value); tui.requestRender();
tui.requestRender(); })();
})(); };
}; selector.onCancel = () => {
selector.onCancel = () => { closeOverlay();
closeOverlay(); tui.requestRender();
tui.requestRender(); };
}; openOverlay(selector);
openOverlay(selector); tui.requestRender();
tui.requestRender(); } catch (err) {
} catch (err) { chatLog.addSystem(`sessions list failed: ${String(err)}`);
chatLog.addSystem(`sessions list failed: ${String(err)}`); tui.requestRender();
tui.requestRender(); }
} };
};
const openSettings = () => { const openSettings = () => {
const items = [ const items = [
{ {
id: "tools", id: "tools",
label: "Tool output", label: "Tool output",
currentValue: state.toolsExpanded ? "expanded" : "collapsed", currentValue: state.toolsExpanded ? "expanded" : "collapsed",
values: ["collapsed", "expanded"], values: ["collapsed", "expanded"],
}, },
{ {
id: "thinking", id: "thinking",
label: "Show thinking", label: "Show thinking",
currentValue: state.showThinking ? "on" : "off", currentValue: state.showThinking ? "on" : "off",
values: ["off", "on"], values: ["off", "on"],
}, },
]; ];
const settings = createSettingsList( const settings = createSettingsList(
items, items,
(id, value) => { (id, value) => {
if (id === "tools") { if (id === "tools") {
state.toolsExpanded = value === "expanded"; state.toolsExpanded = value === "expanded";
chatLog.setToolsExpanded(state.toolsExpanded); chatLog.setToolsExpanded(state.toolsExpanded);
} }
if (id === "thinking") { if (id === "thinking") {
state.showThinking = value === "on"; state.showThinking = value === "on";
void loadHistory(); void loadHistory();
} }
tui.requestRender(); tui.requestRender();
}, },
() => { () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}, },
); );
openOverlay(settings); openOverlay(settings);
tui.requestRender(); tui.requestRender();
}; };
const handleCommand = async (raw: string) => { const handleCommand = async (raw: string) => {
const { name, args } = parseCommand(raw); const { name, args } = parseCommand(raw);
if (!name) return; if (!name) return;
switch (name) { switch (name) {
case "help": case "help":
chatLog.addSystem( chatLog.addSystem(
helpText({ helpText({
provider: state.sessionInfo.modelProvider, provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model, model: state.sessionInfo.model,
}), }),
); );
break; break;
case "status": case "status":
try { try {
const status = await client.getStatus(); const status = await client.getStatus();
if (typeof status === "string") { if (typeof status === "string") {
chatLog.addSystem(status); chatLog.addSystem(status);
break; break;
} }
if (status && typeof status === "object") { if (status && typeof status === "object") {
const lines = formatStatusSummary(status as GatewayStatusSummary); const lines = formatStatusSummary(status as GatewayStatusSummary);
for (const line of lines) chatLog.addSystem(line); for (const line of lines) chatLog.addSystem(line);
break; break;
} }
chatLog.addSystem("status: unknown response"); chatLog.addSystem("status: unknown response");
} catch (err) { } catch (err) {
chatLog.addSystem(`status failed: ${String(err)}`); chatLog.addSystem(`status failed: ${String(err)}`);
} }
break; break;
case "agent": case "agent":
if (!args) { if (!args) {
await openAgentSelector(); await openAgentSelector();
} else { } else {
await setAgent(args); await setAgent(args);
} }
break; break;
case "agents": case "agents":
await openAgentSelector(); await openAgentSelector();
break; break;
case "session": case "session":
if (!args) { if (!args) {
await openSessionSelector(); await openSessionSelector();
} else { } else {
await setSession(args); await setSession(args);
} }
break; break;
case "sessions": case "sessions":
await openSessionSelector(); await openSessionSelector();
break; break;
case "model": case "model":
if (!args) { if (!args) {
await openModelSelector(); await openModelSelector();
} else { } else {
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: args, model: args,
}); });
chatLog.addSystem(`model set to ${args}`); chatLog.addSystem(`model set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
} }
} }
break; break;
case "models": case "models":
await openModelSelector(); await openModelSelector();
break; break;
case "think": case "think":
if (!args) { if (!args) {
const levels = formatThinkingLevels( const levels = formatThinkingLevels(
state.sessionInfo.modelProvider, state.sessionInfo.modelProvider,
state.sessionInfo.model, state.sessionInfo.model,
"|", "|",
); );
chatLog.addSystem(`usage: /think <${levels}>`); chatLog.addSystem(`usage: /think <${levels}>`);
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
thinkingLevel: args, thinkingLevel: args,
}); });
chatLog.addSystem(`thinking set to ${args}`); chatLog.addSystem(`thinking set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`); chatLog.addSystem(`think failed: ${String(err)}`);
} }
break; break;
case "verbose": case "verbose":
if (!args) { if (!args) {
chatLog.addSystem("usage: /verbose <on|off>"); chatLog.addSystem("usage: /verbose <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
verboseLevel: args, verboseLevel: args,
}); });
chatLog.addSystem(`verbose set to ${args}`); chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`); chatLog.addSystem(`verbose failed: ${String(err)}`);
} }
break; break;
case "reasoning": case "reasoning":
if (!args) { if (!args) {
chatLog.addSystem("usage: /reasoning <on|off>"); chatLog.addSystem("usage: /reasoning <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
reasoningLevel: args, reasoningLevel: args,
}); });
chatLog.addSystem(`reasoning set to ${args}`); chatLog.addSystem(`reasoning set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`); chatLog.addSystem(`reasoning failed: ${String(err)}`);
} }
break; break;
case "usage": { case "usage": {
const normalized = args ? normalizeUsageDisplay(args) : undefined; const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) { if (args && !normalized) {
chatLog.addSystem("usage: /usage <off|tokens|full>"); chatLog.addSystem("usage: /usage <off|tokens|full>");
break; break;
} }
const currentRaw = state.sessionInfo.responseUsage; const currentRaw = state.sessionInfo.responseUsage;
const current = resolveResponseUsageMode(currentRaw); const current = resolveResponseUsageMode(currentRaw);
const next = const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
responseUsage: next === "off" ? null : next, responseUsage: next === "off" ? null : next,
}); });
chatLog.addSystem(`usage footer: ${next}`); chatLog.addSystem(`usage footer: ${next}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`); chatLog.addSystem(`usage failed: ${String(err)}`);
} }
break; break;
} }
case "elevated": case "elevated":
if (!args) { if (!args) {
chatLog.addSystem("usage: /elevated <on|off>"); chatLog.addSystem("usage: /elevated <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
elevatedLevel: args, elevatedLevel: args,
}); });
chatLog.addSystem(`elevated set to ${args}`); chatLog.addSystem(`elevated set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`); chatLog.addSystem(`elevated failed: ${String(err)}`);
} }
break; break;
case "activation": case "activation":
if (!args) { if (!args) {
chatLog.addSystem("usage: /activation <mention|always>"); chatLog.addSystem("usage: /activation <mention|always>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention", groupActivation: args === "always" ? "always" : "mention",
}); });
chatLog.addSystem(`activation set to ${args}`); chatLog.addSystem(`activation set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`); chatLog.addSystem(`activation failed: ${String(err)}`);
} }
break; break;
case "new": case "new":
case "reset": case "reset":
try { try {
await client.resetSession(state.currentSessionKey); await client.resetSession(state.currentSessionKey);
chatLog.addSystem(`session ${state.currentSessionKey} reset`); chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory(); await loadHistory();
} catch (err) { } catch (err) {
chatLog.addSystem(`reset failed: ${String(err)}`); chatLog.addSystem(`reset failed: ${String(err)}`);
} }
break; break;
case "abort": case "abort":
await abortActive(); await abortActive();
break; break;
case "settings": case "settings":
openSettings(); openSettings();
break; break;
case "exit": case "exit":
case "quit": case "quit":
client.stop(); client.stop();
tui.stop(); tui.stop();
process.exit(0); process.exit(0);
break; break;
default: default:
chatLog.addSystem(`unknown command: /${name}`); chatLog.addSystem(`unknown command: /${name}`);
break; break;
} }
tui.requestRender(); tui.requestRender();
}; };
const sendMessage = async (text: string) => { const sendMessage = async (text: string) => {
try { try {
chatLog.addUser(text); chatLog.addUser(text);
tui.requestRender(); tui.requestRender();
setActivityStatus("sending"); setActivityStatus("sending");
const { runId } = await client.sendChat({ const { runId } = await client.sendChat({
sessionKey: state.currentSessionKey, sessionKey: state.currentSessionKey,
message: text, message: text,
thinking: opts.thinking, thinking: opts.thinking,
deliver: deliverDefault, deliver: deliverDefault,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
}); });
state.activeChatRunId = runId; state.activeChatRunId = runId;
setActivityStatus("waiting"); setActivityStatus("waiting");
} catch (err) { } catch (err) {
chatLog.addSystem(`send failed: ${String(err)}`); chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error"); setActivityStatus("error");
} }
tui.requestRender(); tui.requestRender();
}; };
return { return {
handleCommand, handleCommand,
sendMessage, sendMessage,
openModelSelector, openModelSelector,
openAgentSelector, openAgentSelector,
openSessionSelector, openSessionSelector,
openSettings, openSettings,
setAgent, setAgent,
}; };
} }

15
src/utils/time-format.ts Normal file
View File

@@ -0,0 +1,15 @@
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}