import { fuzzyMatch } from "@earendil-works/pi-tui"; import type { SessionInfo } from "../../../core/session-manager.ts"; export type SortMode = "threaded" | "recent" | "relevance"; export type NameFilter = "all" | "named"; export interface ParsedSearchQuery { mode: "tokens" | "regex"; tokens: { kind: "fuzzy" | "phrase"; value: string }[]; regex: RegExp | null; /** If set, parsing failed and we should treat query as non-matching. */ error?: string; } export interface MatchResult { matches: boolean; /** Lower is better; only meaningful when matches === true */ score: number; } function normalizeWhitespaceLower(text: string): string { return text.toLowerCase().replace(/\s+/g, " ").trim(); } function getSessionSearchText(session: SessionInfo): string { return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; } export function hasSessionName(session: SessionInfo): boolean { return Boolean(session.name?.trim()); } function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean { if (filter === "all") return true; return hasSessionName(session); } export function parseSearchQuery(query: string): ParsedSearchQuery { const trimmed = query.trim(); if (!trimmed) { return { mode: "tokens", tokens: [], regex: null }; } // Regex mode: re: if (trimmed.startsWith("re:")) { const pattern = trimmed.slice(3).trim(); if (!pattern) { return { mode: "regex", tokens: [], regex: null, error: "Empty regex" }; } try { return { mode: "regex", tokens: [], regex: new RegExp(pattern, "i") }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { mode: "regex", tokens: [], regex: null, error: msg }; } } // Token mode with quote support. // Example: foo "node cve" bar const tokens: { kind: "fuzzy" | "phrase"; value: string }[] = []; let buf = ""; let inQuote = false; let hadUnclosedQuote = false; const flush = (kind: "fuzzy" | "phrase"): void => { const v = buf.trim(); buf = ""; if (!v) return; tokens.push({ kind, value: v }); }; for (let i = 0; i < trimmed.length; i++) { const ch = trimmed[i]!; if (ch === '"') { if (inQuote) { flush("phrase"); inQuote = false; } else { flush("fuzzy"); inQuote = true; } continue; } if (!inQuote && /\s/.test(ch)) { flush("fuzzy"); continue; } buf += ch; } if (inQuote) { hadUnclosedQuote = true; } // If quotes were unbalanced, fall back to plain whitespace tokenization. if (hadUnclosedQuote) { return { mode: "tokens", tokens: trimmed .split(/\s+/) .map((t) => t.trim()) .filter((t) => t.length > 0) .map((t) => ({ kind: "fuzzy" as const, value: t })), regex: null, }; } flush(inQuote ? "phrase" : "fuzzy"); return { mode: "tokens", tokens, regex: null }; } export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult { const text = getSessionSearchText(session); if (parsed.mode === "regex") { if (!parsed.regex) { return { matches: false, score: 0 }; } const idx = text.search(parsed.regex); if (idx < 0) return { matches: false, score: 0 }; return { matches: true, score: idx * 0.1 }; } if (parsed.tokens.length === 0) { return { matches: true, score: 0 }; } let totalScore = 0; let normalizedText: string | null = null; for (const token of parsed.tokens) { if (token.kind === "phrase") { if (normalizedText === null) { normalizedText = normalizeWhitespaceLower(text); } const phrase = normalizeWhitespaceLower(token.value); if (!phrase) continue; const idx = normalizedText.indexOf(phrase); if (idx < 0) return { matches: false, score: 0 }; totalScore += idx * 0.1; continue; } const m = fuzzyMatch(token.value, text); if (!m.matches) return { matches: false, score: 0 }; totalScore += m.score; } return { matches: true, score: totalScore }; } export function filterAndSortSessions( sessions: SessionInfo[], query: string, sortMode: SortMode, nameFilter: NameFilter = "all", ): SessionInfo[] { const nameFiltered = nameFilter === "all" ? sessions : sessions.filter((session) => matchesNameFilter(session, nameFilter)); const trimmed = query.trim(); if (!trimmed) return nameFiltered; const parsed = parseSearchQuery(query); if (parsed.error) return []; // Recent mode: filter only, keep incoming order. if (sortMode === "recent") { const filtered: SessionInfo[] = []; for (const s of nameFiltered) { const res = matchSession(s, parsed); if (res.matches) filtered.push(s); } return filtered; } // Relevance mode: sort by score, tie-break by modified desc. const scored: { session: SessionInfo; score: number }[] = []; for (const s of nameFiltered) { const res = matchSession(s, parsed); if (!res.matches) continue; scored.push({ session: s, score: res.score }); } scored.sort((a, b) => { if (a.score !== b.score) return a.score - b.score; return b.session.modified.getTime() - a.session.modified.getTime(); }); return scored.map((r) => r.session); }