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:
committed by
Peter Steinberger
parent
1d9d5b30ce
commit
a28c271488
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
15
src/utils/time-format.ts
Normal 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" });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user