UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

219 lines • 7.82 kB
import { fuzzyMatch } from "@earendil-works/pi-tui"; function normalizeWhitespaceLower(text) { return text.toLowerCase().replace(/\s+/g, " ").trim(); } /** * Match tiers, best first. Each token is scored by the best field it matches; tiers dominate * fuzzy scores so an exact hit anywhere beats a scattered subsequence in a short field. */ const TIER_NAME = 0; // query text appears in the session name const TIER_TEXT = 1; // query text appears in the session id, messages, or cwd const TIER_FUZZY = 2; // query characters appear scattered in the session name or cwd const TIER_WEIGHT = 1_000_000; /** * Lowercased message text per session. allMessagesText holds every user/assistant message and * can be megabytes, so lowercase it once instead of on every keystroke. */ const messageTextCache = new WeakMap(); function getMessageText(session) { let cached = messageTextCache.get(session); if (!cached || cached.source !== session.allMessagesText) { cached = { source: session.allMessagesText, lower: session.allMessagesText.toLowerCase() }; messageTextCache.set(session, cached); } return cached; } function getNormalizedMessageText(session) { const cached = getMessageText(session); cached.normalized ??= cached.lower.replace(/\s+/g, " ").trim(); return cached.normalized; } function tierScore(tier, score = 0) { return tier * TIER_WEIGHT + score; } /** Exact (case-insensitive) text match. Messages and ids are only matched exactly: scattered matches in them are noise. */ function matchLiteralToken(session, value) { const name = session.name?.toLowerCase() ?? ""; if (name.includes(value)) return tierScore(TIER_NAME, fuzzyMatch(value, name).score); if (session.id.toLowerCase().includes(value)) return tierScore(TIER_TEXT); if (getMessageText(session).lower.includes(value)) return tierScore(TIER_TEXT); if (session.cwd.toLowerCase().includes(value)) return tierScore(TIER_TEXT); return undefined; } function matchFuzzyToken(session, value) { const literal = matchLiteralToken(session, value.toLowerCase()); if (literal !== undefined) return literal; let best; for (const field of [session.name ?? "", session.cwd]) { const m = fuzzyMatch(value, field); if (m.matches && (best === undefined || m.score < best)) best = m.score; } return best === undefined ? undefined : tierScore(TIER_FUZZY, best); } function matchPhraseToken(session, phrase) { const name = normalizeWhitespaceLower(session.name ?? ""); if (name.includes(phrase)) return tierScore(TIER_NAME, fuzzyMatch(phrase, name).score); if (session.id.toLowerCase().includes(phrase)) return tierScore(TIER_TEXT); if (getNormalizedMessageText(session).includes(phrase)) return tierScore(TIER_TEXT); if (normalizeWhitespaceLower(session.cwd).includes(phrase)) return tierScore(TIER_TEXT); return undefined; } function matchRegex(session, regex) { if (session.name && regex.test(session.name)) return tierScore(TIER_NAME); if (regex.test(session.id) || regex.test(session.allMessagesText) || regex.test(session.cwd)) { return tierScore(TIER_TEXT); } return undefined; } export function hasSessionName(session) { return Boolean(session.name?.trim()); } function matchesNameFilter(session, filter) { if (filter === "all") return true; return hasSessionName(session); } export function parseSearchQuery(query) { const trimmed = query.trim(); if (!trimmed) { return { mode: "tokens", tokens: [], regex: null }; } // Regex mode: re:<pattern> 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 = []; let buf = ""; let inQuote = false; let hadUnclosedQuote = false; const flush = (kind) => { 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", value: t })), regex: null, }; } flush(inQuote ? "phrase" : "fuzzy"); return { mode: "tokens", tokens, regex: null }; } /** * Each field is matched separately, so a token never matches across field boundaries. * Different tokens may match different fields. */ export function matchSession(session, parsed) { if (parsed.mode === "regex") { if (!parsed.regex) { return { matches: false, score: 0 }; } const score = matchRegex(session, parsed.regex); return score === undefined ? { matches: false, score: 0 } : { matches: true, score }; } let totalScore = 0; for (const token of parsed.tokens) { let score; if (token.kind === "phrase") { const phrase = normalizeWhitespaceLower(token.value); if (!phrase) continue; score = matchPhraseToken(session, phrase); } else { score = matchFuzzyToken(session, token.value); } if (score === undefined) return { matches: false, score: 0 }; totalScore += score; } return { matches: true, score: totalScore }; } export function filterAndSortSessions(sessions, query, sortMode, nameFilter = "all") { 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 = []; 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 = []; 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); } //# sourceMappingURL=session-selector-search.js.map