Updated extensions and global .gitignore

This commit is contained in:
Alex Selimov 2026-05-26 10:30:21 -04:00
parent 19f3c6a050
commit 411be9ab83
4 changed files with 243 additions and 0 deletions

View file

@ -0,0 +1,204 @@
/**
* search-history extension
*
* Search through all of your chat histories and jump to a session.
*
* Usage:
* /search-history <query> Search all sessions across all projects
* /search-history --here <query> Search only sessions for the current cwd
* /search-history --re <regex> Treat query as a regex (case-insensitive)
*
* Behavior:
* - Lists every session whose conversation text matches the query
* - Shows the matching line as the selection label so you can disambiguate
* - Selecting a session switches to it at its existing leaf (latest message)
*/
import type { ExtensionAPI, SessionInfo } from "@earendil-works/pi-coding-agent";
import { SessionManager } from "@earendil-works/pi-coding-agent";
interface Match {
info: SessionInfo;
line: string; // matched line (or windowed snippet)
lineNumber: number;
}
const MAX_RESULTS = 200;
const SNIPPET_RADIUS = 60; // chars around match if line is too long
const MAX_LINE_DISPLAY = 160;
function compactWhitespace(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
function snippetAround(line: string, idx: number, qlen: number): string {
if (line.length <= MAX_LINE_DISPLAY) return line;
const start = Math.max(0, idx - SNIPPET_RADIUS);
const end = Math.min(line.length, idx + qlen + SNIPPET_RADIUS);
const prefix = start > 0 ? "…" : "";
const suffix = end < line.length ? "…" : "";
return prefix + line.slice(start, end) + suffix;
}
function findMatch(
haystack: string,
pattern: RegExp,
): { line: string; lineNumber: number } | undefined {
if (!haystack) return undefined;
const lines = haystack.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
pattern.lastIndex = 0;
const m = pattern.exec(line);
if (m) {
const cleaned = compactWhitespace(line);
// Re-locate the match in the cleaned string for snippet windowing
pattern.lastIndex = 0;
const m2 = pattern.exec(cleaned);
const idx = m2 ? m2.index : 0;
const qlen = m2 ? m2[0].length : m[0].length;
return { line: snippetAround(cleaned, idx, qlen), lineNumber: i + 1 };
}
}
return undefined;
}
function buildPattern(query: string, isRegex: boolean): RegExp | undefined {
try {
if (isRegex) return new RegExp(query, "i");
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped, "i");
} catch {
return undefined;
}
}
function relTime(date: Date): string {
const diffMs = Date.now() - date.getTime();
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const d = Math.floor(hr / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
return `${Math.floor(mo / 12)}y ago`;
}
function projectLabel(cwd: string): string {
if (!cwd) return "(unknown)";
const parts = cwd.split("/").filter(Boolean);
return parts[parts.length - 1] ?? cwd;
}
function formatItem(m: Match): string {
const when = relTime(m.info.modified);
const proj = projectLabel(m.info.cwd);
const title = m.info.name?.trim() || compactWhitespace(m.info.firstMessage) || "(empty)";
const titleTrim = title.length > 60 ? title.slice(0, 59) + "…" : title;
const lineTrim = m.line.length > MAX_LINE_DISPLAY ? m.line.slice(0, MAX_LINE_DISPLAY - 1) + "…" : m.line;
return `[${when}] ${proj}${titleTrim}\n ↳ ${lineTrim}`;
}
function parseArgs(raw: string): { query: string; here: boolean; regex: boolean } {
const tokens = raw.trim().split(/\s+/).filter(Boolean);
let here = false;
let regex = false;
const rest: string[] = [];
for (const t of tokens) {
if (t === "--here" || t === "-h") here = true;
else if (t === "--re" || t === "--regex" || t === "-r") regex = true;
else rest.push(t);
}
return { query: rest.join(" "), here, regex };
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("search-history", {
description: "Search all chat histories and jump to a session",
handler: async (rawArgs, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("search-history requires interactive mode", "error");
return;
}
const { query, here, regex } = parseArgs(rawArgs);
if (!query) {
ctx.ui.notify(
"Usage: /search-history [--here] [--re] <query>",
"error",
);
return;
}
const pattern = buildPattern(query, regex);
if (!pattern) {
ctx.ui.notify(`Invalid regex: ${query}`, "error");
return;
}
ctx.ui.setStatus("search-history", `searching: ${query}`);
let sessions: SessionInfo[];
try {
sessions = here
? await SessionManager.list(ctx.cwd)
: await SessionManager.listAll();
} catch (err) {
ctx.ui.setStatus("search-history", undefined);
ctx.ui.notify(`Failed to list sessions: ${(err as Error).message}`, "error");
return;
}
const currentFile = ctx.sessionManager.getSessionFile();
const matches: Match[] = [];
for (const info of sessions) {
if (info.path === currentFile) continue; // skip self
const hit = findMatch(info.allMessagesText ?? "", pattern);
if (hit) {
matches.push({ info, line: hit.line, lineNumber: hit.lineNumber });
if (matches.length >= MAX_RESULTS) break;
}
}
ctx.ui.setStatus("search-history", undefined);
if (matches.length === 0) {
ctx.ui.notify(`No sessions match "${query}"`, "info");
return;
}
// Most recent first
matches.sort((a, b) => b.info.modified.getTime() - a.info.modified.getTime());
const items = matches.map(formatItem);
const selected = await ctx.ui.select(
`History matches for "${query}" (${matches.length})`,
items,
);
if (!selected) return;
const chosen = matches[items.indexOf(selected)];
if (!chosen) return;
const result = await ctx.switchSession(chosen.info.path, {
withSession: async (newCtx) => {
newCtx.ui.notify(
`Resumed: ${projectLabel(chosen.info.cwd)}${
chosen.info.name?.trim() ||
compactWhitespace(chosen.info.firstMessage) ||
"session"
}`,
"info",
);
},
});
if (result.cancelled) {
ctx.ui.notify("Switch cancelled", "info");
}
},
});
}