/** * Session Manager Extension * * One command to browse, switch, delete sessions, and inspect their content. * * Usage: * /sessions - Open interactive session browser * * Keyboard shortcuts in browser: * ↑/↓ Navigate sessions * Enter Switch to selected session * i Toggle session details * d Delete selected session (press d twice to confirm) * / Start filtering by name/project/model * Esc Close */ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { matchesKey, Key, truncateToWidth, Container, Text, Spacer } from "@earendil-works/pi-tui"; import { readdirSync, readFileSync, existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; // ─── Types ─────────────────────────────────────────────────────────────────── interface SessionInfo { file: string; dirLabel: string; id: string; timestamp: string; entries: number; label: string; model?: string; cwd: string; tokens: number; cost: number; } interface TotalStats { sessions: number; tokens: number; cost: number; } interface SessionHeader { type: string; version: number; id: string; timestamp: string; cwd: string; } // ─── Helpers ───────────────────────────────────────────────────────────────── function fmtNum(n: number): string { if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; if (n >= 1000) return (n / 1000).toFixed(1) + "k"; return String(Math.round(n)); } function fmtCost(n: number): string { if (n === 0) return "$0"; if (n < 0.01) return "<$0.01"; return "$" + n.toFixed(2); } function getSessionDir(): string { return process.env["PI_CODING_AGENT_SESSION_DIR"] || join(homedir(), ".pi", "agent", "sessions"); } function parseSessionHeader(fp: string): SessionHeader | null { try { const line = readFileSync(fp, "utf-8").split("\n")[0]; return line ? JSON.parse(line) : null; } catch { return null; } } function countEntries(fp: string): number { try { return readFileSync(fp, "utf-8").trim().split("\n").length - 1; } catch { return 0; } } function findSessionLabel(fp: string): string | undefined { try { const content = readFileSync(fp, "utf-8"); for (const line of content.split("\n")) { if (!line) continue; try { const e = JSON.parse(line); if (e.type === "session_info" && e.name) return e.name; } catch { continue; } } for (const line of content.split("\n")) { if (!line) continue; try { const e = JSON.parse(line); if (e.type === "message" && e.message?.role === "user") { const c = e.message.content; if (typeof c === "string") return c.slice(0, 80); if (Array.isArray(c)) { for (const block of c) { if (block?.type === "text" && block.text) return block.text.slice(0, 80); } } } } catch { continue; } } } catch { /* ignore */ } return undefined; } function findLastModel(fp: string): string | undefined { try { const lines = readFileSync(fp, "utf-8").trim().split("\n"); for (let i = lines.length - 1; i >= 0; i--) { try { const e = JSON.parse(lines[i]); if (e.type === "message" && e.message?.role === "assistant" && e.message.model) { return e.message.model; } if (e.type === "model_change" && e.modelId) { return e.provider + "/" + e.modelId; } } catch { continue; } } } catch { /* ignore */ } return undefined; } function formatTime(iso: string): string { try { const d = new Date(iso); const diff = Math.floor((Date.now() - d.getTime()) / 86400000); if (diff === 0) return "Today"; if (diff === 1) return "Yesterday"; if (diff < 7) return diff + "d ago"; const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return m + "-" + day; } catch { return iso; } } function formatPath(path: string): string { return path.replace(homedir(), "~"); } function scanSessionUsage(fp: string): { tokens: number; cost: number } { let tokens = 0, cost = 0; try { for (const line of readFileSync(fp, "utf-8").split("\n")) { if (!line) continue; try { const e = JSON.parse(line); const u = e.message?.usage || e.usage; if (!u) continue; tokens += u.totalTokens || 0; if (u.cost?.total) cost += u.cost.total; } catch { continue; } } } catch { /* ignore */ } return { tokens, cost }; } function scanAllSessions(): { sessions: SessionInfo[]; total: TotalStats } { const sdir = getSessionDir(); const total: TotalStats = { sessions: 0, tokens: 0, cost: 0 }; if (!existsSync(sdir)) return { sessions: [], total }; const sessions: SessionInfo[] = []; for (const dirEntry of readdirSync(sdir, { withFileTypes: true })) { if (!dirEntry.isDirectory()) continue; const projectDir = join(sdir, dirEntry.name); const dirLabel = dirEntry.name.replace(/^--/, "").replace(/--$/, "").replace(/--/g, "/"); for (const file of readdirSync(projectDir)) { if (!file.endsWith(".jsonl")) continue; const fp = join(projectDir, file); const header = parseSessionHeader(fp); if (!header) continue; const usage = scanSessionUsage(fp); if (usage.tokens > 0) { total.tokens += usage.tokens; total.cost += usage.cost; } total.sessions++; sessions.push({ file: fp, dirLabel, id: header.id, timestamp: header.timestamp, cwd: header.cwd, entries: countEntries(fp), label: findSessionLabel(fp) || "(unnamed)", model: findLastModel(fp), tokens: usage.tokens, cost: usage.cost, }); } } sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return { sessions, total }; } // ─── UI Component ──────────────────────────────────────────────────────────── class SessionBrowser extends Container { private sessions: SessionInfo[]; private filtered: SessionInfo[] = []; private total: TotalStats; private theme: Theme; selectedIndex: number = 0; private filterText: string = ""; private filterActive: boolean = false; private showDetail: boolean = false; private detailSession: SessionInfo | null = null; private deleteConfirmIndex: number = -1; private statusMessage: string = ""; private statusTimeout: ReturnType | null = null; private onSelectCb: ((session: SessionInfo) => void) | null = null; private onCloseCb: (() => void) | null = null; onSelect(cb: (session: SessionInfo) => void) { this.onSelectCb = cb; return this; } onClose(cb: () => void) { this.onCloseCb = cb; return this; } constructor(sessions: SessionInfo[], total: TotalStats, theme: Theme, initialIndex: number = 0) { super(); this.sessions = sessions; this.total = total; this.theme = theme; this.selectedIndex = Math.min(initialIndex, sessions.length - 1); this.applyFilter(); this.rebuild(); } private applyFilter() { const q = this.filterText.toLowerCase(); this.filtered = q ? this.sessions.filter( (s) => s.label.toLowerCase().includes(q) || s.dirLabel.toLowerCase().includes(q) || (s.model || "").toLowerCase().includes(q), ) : [...this.sessions]; if (this.selectedIndex >= this.filtered.length) { this.selectedIndex = Math.max(0, this.filtered.length - 1); } } private clearStatus() { this.statusMessage = ""; if (this.statusTimeout) { clearTimeout(this.statusTimeout); this.statusTimeout = null; } } private setStatus(msg: string) { this.statusMessage = msg; if (this.statusTimeout) clearTimeout(this.statusTimeout); this.statusTimeout = setTimeout(() => { this.statusMessage = ""; this.rebuild(); }, 3000); this.rebuild(); } private rebuild() { this.clear(); const th = this.theme; // ── Title ── this.addChild(new Text(th.fg("accent", "──────────────────── Session Browser ───────────────────"), 0, 0)); // ── Stats ── const tokStr = fmtNum(this.total.tokens) + " tok"; const costStr = fmtCost(this.total.cost); this.addChild(new Text( " " + th.fg("dim", `${this.total.sessions} sess · ${tokStr} · ${costStr}`), 0, 0, )); // ── Status / filter bar ── if (this.filterActive || this.filterText) { const cursor = this.filterActive ? th.fg("accent", "█") : ""; this.addChild(new Text(th.fg("dim", "Filter: ") + this.filterText + cursor, 0, 0)); } else if (this.statusMessage) { this.addChild(new Text(th.fg("warning", this.statusMessage), 0, 0)); } if (this.showDetail && this.detailSession) { // ── Detail view ── const s = this.detailSession; this.addChild(new Text("", 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Prompt:") + " " + (s.label || "(unnamed)"), 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Project:") + " " + s.dirLabel, 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Created:") + " " + new Date(s.timestamp).toLocaleString(), 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Entries:") + " " + s.entries, 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Tokens:") + " " + fmtNum(s.tokens) + " (" + fmtCost(s.cost) + ")", 0, 0)); this.addChild(new Text(" " + th.fg("accent", "Model:") + " " + (s.model || "(unknown)"), 0, 0)); this.addChild(new Text(" " + th.fg("accent", "File:") + " " + formatPath(s.file), 0, 0)); this.addChild(new Text(" " + th.fg("accent", "ID:") + " " + s.id, 0, 0)); this.addChild(new Text("", 0, 0)); this.addChild(new Text( " " + th.fg("dim", `${this.filtered.length} session${this.filtered.length > 1 ? "s" : ""} · Esc to go back`), 0, 0, )); } else { // ── List view ── if (this.filtered.length === 0) { this.addChild(new Text(" " + th.fg("dim", "No sessions found"), 0, 0)); } else { this.addChild(new Text( " " + th.fg("dim", `${this.filtered.length} session${this.filtered.length > 1 ? "s" : ""}`), 0, 0, )); for (const s of this.filtered) { const idx = this.filtered.indexOf(s); const sel = idx === this.selectedIndex; const confirmDel = sel && this.deleteConfirmIndex === this.selectedIndex; const age = formatTime(s.timestamp); const msgs = s.entries + "msgs"; const mdl = s.model ? s.model.split("/").pop() || s.model : ""; const displayLabel = s.label.length > 50 ? s.label.slice(0, 47) + "..." : s.label; let text = displayLabel; text += " · " + age + " · " + msgs; if (mdl) text += " · " + mdl; if (confirmDel) { this.addChild(new Text( th.bg("toolErrorBg", th.fg("error", " " + text + " ")), 0, 0, )); } else if (sel) { this.addChild(new Text( th.bg("selectedBg", th.fg("accent", " " + text + " ")), 0, 0, )); } else { this.addChild(new Text( " " + th.fg("muted", text), 0, 0, )); } } } } // ── Help bar ── this.addChild(new Text( "\n" + th.fg("dim", "↑↓ nav Enter→switch i info d delete / filter Esc close"), 0, 0, )); } override invalidate(): void { super.invalidate(); } handleInput(data: string): void { if (this.filterActive) { if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) { this.filterActive = false; this.clearStatus(); this.rebuild(); return; } if (data === "\b" || data === "\x7f") { this.filterText = this.filterText.slice(0, -1); this.applyFilter(); this.rebuild(); return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { this.filterText += data; this.applyFilter(); this.rebuild(); return; } return; } if (matchesKey(data, Key.up)) { if (this.selectedIndex > 0) { this.selectedIndex--; if (this.showDetail) { this.showDetail = false; this.detailSession = null; } this.rebuild(); } } else if (matchesKey(data, Key.down)) { if (this.selectedIndex < this.filtered.length - 1) { this.selectedIndex++; if (this.showDetail) { this.showDetail = false; this.detailSession = null; } this.rebuild(); } } else if (matchesKey(data, Key.enter)) { this.onSelectCb?.(this.filtered[this.selectedIndex]); } else if (matchesKey(data, Key.escape)) { if (this.filterText) { this.filterText = ""; this.filterActive = false; this.applyFilter(); this.rebuild(); } else if (this.showDetail) { this.showDetail = false; this.detailSession = null; this.rebuild(); } else { this.onCloseCb?.(); } } else if (data === "i" || data === "I") { this.showDetail = !this.showDetail; this.detailSession = this.filtered[this.selectedIndex] || null; this.rebuild(); } else if (data === "d" || data === "D") { if (this.deleteConfirmIndex === this.selectedIndex) { const s = this.filtered[this.selectedIndex]; if (s) { try { unlinkSync(s.file); this.sessions = this.sessions.filter((x) => x.file !== s.file); this.applyFilter(); if (this.selectedIndex >= this.filtered.length) this.selectedIndex = Math.max(0, this.filtered.length - 1); this.setStatus("Deleted: " + s.label.slice(0, 40)); } catch { this.setStatus("Failed to delete session file"); } } this.deleteConfirmIndex = -1; this.rebuild(); } else { this.deleteConfirmIndex = this.selectedIndex; this.setStatus("Press d again to confirm delete"); this.rebuild(); } } else if (data === "/") { this.filterText = ""; this.filterActive = true; this.setStatus("Type to filter..."); this.rebuild(); } } } // ─── Extension ─────────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { function updateSessionStatus(ctx: ExtensionContext) { const name = ctx.sessionManager?.getSessionName?.(); const ui = ctx.ui; if (!ui) return; if (name) { ui.setStatus("session-name", ui.theme.fg("accent", "\uD83D\uDCC1 " + name)); } else { ui.setStatus("session-name", undefined); } } pi.on("session_start", async (_event, ctx) => updateSessionStatus(ctx)); pi.on("session_info_changed", async (_event, ctx) => updateSessionStatus(ctx)); pi.registerCommand("sessions", { description: "Browse and manage sessions (switch, info, delete)", getArgumentCompletions: () => null, handler: async (_args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("/sessions requires interactive mode", "error"); return; } ctx.ui.notify("Scanning sessions...", "info"); const { sessions, total } = scanAllSessions(); if (sessions.length === 0) { ctx.ui.notify("No sessions found", "warning"); return; } const currentFile = ctx.sessionManager.getSessionFile(); const currentIndex = currentFile ? sessions.findIndex((s) => s.file === currentFile) : -1; await ctx.ui.custom((_tui, theme, _kb, done) => { const browser = new SessionBrowser(sessions, total, theme, currentIndex >= 0 ? currentIndex : 0); browser.onSelect(async (session) => { if (session.file === currentFile) { ctx.ui.notify("Already in this session", "info"); return; } done(); ctx.ui.notify("Switching to session...", "info"); try { await ctx.switchSession(session.file, { withSession: async (newCtx) => { newCtx.ui.notify("Switched to: " + session.label.slice(0, 50), "info"); }, }); } catch (err) { ctx.ui.notify("Switch failed: " + err, "error"); } }); browser.onClose(() => done()); return browser; }); }, }); }