Commit fc1ad7ffb7b6

Vincent Demeester <vincent@sbr.pm>
2026-02-25 12:18:09
feat(pi): add session picker overlay to /session-export
Replaced broken /export-session command (intercepted by built-in /export handler) with /session-export using a split-pane overlay picker with fuzzy search, markdown preview, and PgUp/PgDn scrolling. Strips terminal escape sequences from recovered session files to prevent TUI width overflow crashes.
1 parent f6ff8b7
Changed files (1)
dots
pi
agent
extensions
ai-storage
dots/pi/agent/extensions/ai-storage/index.ts
@@ -26,10 +26,10 @@
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
-import { Box, Markdown } from "@mariozechner/pi-tui";
+import { Box, Markdown, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
 import { Type } from "@sinclair/typebox";
 import { writeFile, mkdir, appendFile, readFile, readdir, unlink } from "node:fs/promises";
-import { existsSync, openSync } from "node:fs";
+import { existsSync, openSync, readdirSync } from "node:fs";
 import { join, dirname } from "node:path";
 import { homedir, hostname } from "node:os";
 import { spawn } from "node:child_process";
@@ -1141,9 +1141,289 @@ After generating the summary, use the save_session_to_history tool to save it${c
 		},
 	});
 
-	// Register /export-session command
-	pi.registerCommand("export-session", {
-		description: "Export a saved session to HTML. Usage: /export-session [path-to-summary.md] or interactive picker",
+	// ========================================================================
+	// Session picker overlay component
+	// ========================================================================
+
+	function collectSessionFiles(months: number = 3): SessionFile[] {
+		const allFiles: SessionFile[] = [];
+		const now = new Date();
+
+		for (let i = 0; i < months; i++) {
+			const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+			const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+			const sessionDir = join(SESSIONS_DIR, ym);
+
+			if (!existsSync(sessionDir)) continue;
+
+			const dirFiles = readdirSync(sessionDir);
+			for (const f of dirFiles) {
+				if (!f.endsWith(".md") || f.includes("session-log")) continue;
+				const fullPath = join(sessionDir, f);
+				const date = f.slice(0, 10);
+				const desc = f.slice(11).replace(".md", "").replace(/-/g, " ");
+				allFiles.push({ date, file: f, path: fullPath, desc });
+			}
+		}
+
+		// Newest first
+		allFiles.sort((a, b) => b.file.localeCompare(a.file));
+		return allFiles;
+	}
+
+	function fuzzyMatch(query: string, text: string): number {
+		const lq = query.toLowerCase();
+		const lt = text.toLowerCase();
+		if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
+
+		let score = 0, qi = 0, bonus = 0;
+		for (let i = 0; i < lt.length && qi < lq.length; i++) {
+			if (lt[i] === lq[qi]) { score += 10 + bonus; bonus += 5; qi++; }
+			else { bonus = 0; }
+		}
+		return qi === lq.length ? score : 0;
+	}
+
+	class SessionPickerComponent {
+		private readonly maxVisible = 20;
+		private allFiles: SessionFile[];
+		private filtered: SessionFile[];
+		private selected = 0;
+		private query = "";
+		private done: (result: string | null) => void;
+		private previewCache = new Map<string, string[]>();
+		private previewScroll = 0;
+
+		constructor(files: SessionFile[], done: (result: string | null) => void) {
+			this.allFiles = files;
+			this.filtered = files;
+			this.done = done;
+		}
+
+		handleInput(data: string): void {
+			if (matchesKey(data, "escape")) {
+				this.done(null);
+				return;
+			}
+
+			if (matchesKey(data, "return")) {
+				const entry = this.filtered[this.selected];
+				this.done(entry ? entry.path : null);
+				return;
+			}
+
+			if (matchesKey(data, "up")) {
+				if (this.filtered.length > 0) {
+					this.selected = this.selected === 0 ? this.filtered.length - 1 : this.selected - 1;
+					this.previewScroll = 0;
+				}
+				return;
+			}
+
+			if (matchesKey(data, "down")) {
+				if (this.filtered.length > 0) {
+					this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
+					this.previewScroll = 0;
+				}
+				return;
+			}
+
+			if (matchesKey(data, "pageUp")) {
+				this.previewScroll = Math.max(0, this.previewScroll - this.maxVisible);
+				return;
+			}
+
+			if (matchesKey(data, "pageDown")) {
+				const entry = this.filtered[this.selected];
+				if (entry) {
+					const lines = this.getPreviewLines(entry.path);
+					const maxScroll = Math.max(0, lines.length - this.maxVisible);
+					this.previewScroll = Math.min(this.previewScroll + this.maxVisible, maxScroll);
+				}
+				return;
+			}
+
+			if (matchesKey(data, "backspace")) {
+				if (this.query.length > 0) {
+					this.query = this.query.slice(0, -1);
+					this.updateFilter();
+				}
+				return;
+			}
+
+			if (data.length === 1 && data.charCodeAt(0) >= 32) {
+				this.query += data;
+				this.updateFilter();
+			}
+		}
+
+		private updateFilter(): void {
+			if (!this.query.trim()) {
+				this.filtered = this.allFiles;
+			} else {
+				const scored = this.allFiles
+					.map((f) => ({ f, score: Math.max(fuzzyMatch(this.query, f.desc), fuzzyMatch(this.query, f.date)) }))
+					.filter((x) => x.score > 0)
+					.sort((a, b) => b.score - a.score);
+				this.filtered = scored.map((x) => x.f);
+			}
+			this.selected = 0;
+			this.previewScroll = 0;
+		}
+
+		private getPreviewLines(filePath: string): string[] {
+			if (this.previewCache.has(filePath)) {
+				return this.previewCache.get(filePath)!;
+			}
+			try {
+				const { readFileSync } = require("node:fs") as typeof import("node:fs");
+				let content = readFileSync(filePath, "utf-8");
+				// Strip ANSI escape sequences, OSC sequences, and other terminal controls
+				// that may be embedded in recovered session transcripts
+				content = content.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ""); // OSC sequences
+				content = content.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // CSI sequences
+				content = content.replace(/\x1b[^[\]]/g, ""); // Other ESC sequences
+				const lines = content.split("\n");
+				this.previewCache.set(filePath, lines);
+				return lines;
+			} catch {
+				const fallback = ["(unable to read file)"];
+				this.previewCache.set(filePath, fallback);
+				return fallback;
+			}
+		}
+
+		render(width: number): string[] {
+			// Layout: totalW includes outer borders
+			// │<leftW>│<rightW>│ = 3 border chars + leftW + rightW = totalW
+			const maxW = width; // hard limit — no line may exceed this
+			const totalW = Math.min(width - 2, 140);
+			const innerW = totalW - 2; // for full-width rows (outer borders only)
+			const leftW = Math.min(42, Math.floor(totalW * 0.35));
+			const rightW = totalW - 3 - leftW; // 3 = left border + middle border + right border
+			const lines: string[] = [];
+
+			const c = (code: string, text: string) => code ? `\x1b[${code}m${text}\x1b[0m` : text;
+			const border = (s: string) => c("2", s);
+			const accent = (s: string) => c("36", s);
+			const dim = (s: string) => c("2", s);
+			const muted = (s: string) => c("90", s);
+			const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
+
+			// Truncate with ANSI awareness, then pad to exact width
+			const trunc = (s: string, w: number) => truncateToWidth(s, w, "…");
+			const fit = (s: string, w: number) => truncateToWidth(s, w, "…", true);
+
+			const splitRow = (left: string, right: string) =>
+				border("│") + fit(left, leftW) + border("│") + fit(right, rightW) + border("│");
+
+			const fullRow = (content: string) =>
+				border("│") + fit(content, innerW) + border("│");
+
+			// ── Top border ──
+			const titleText = " Export Session ";
+			const bLen = Math.max(0, innerW - visibleWidth(titleText));
+			const lB = Math.floor(bLen / 2);
+			lines.push(border("╭" + "─".repeat(lB)) + accent(titleText) + border("─".repeat(bLen - lB) + "╮"));
+
+			// ── Search bar (full width) ──
+			const prompt = accent("❯ ");
+			const queryDisplay = this.query || dim("Type to filter...");
+			lines.push(fullRow(` ${prompt}${queryDisplay}`));
+
+			// ── Divider with T-junction for middle column ──
+			lines.push(border("├" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "┤"));
+
+			// ── Get preview content for selected entry ──
+			const selectedEntry = this.filtered[this.selected];
+			let previewLines: string[] = [];
+			if (selectedEntry) {
+				previewLines = this.getPreviewLines(selectedEntry.path);
+			}
+
+			// ── Body rows (list + preview side by side) ──
+			const startIndex = Math.max(
+				0,
+				Math.min(this.selected - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible)
+			);
+
+			for (let i = 0; i < this.maxVisible; i++) {
+				// Left pane: session list
+				let leftContent = "";
+				const idx = startIndex + i;
+				if (idx < this.filtered.length) {
+					const entry = this.filtered[idx];
+					const isSel = idx === this.selected;
+					const prefix = isSel ? accent("▶ ") : "  ";
+					const dateStr = dim(entry.date.slice(5)); // MM-DD to save space
+					const descStr = trunc(entry.desc, leftW - 10);
+					const name = isSel ? bold(accent(descStr)) : descStr;
+					leftContent = `${prefix}${dateStr} ${name}`;
+				} else if (i === 0 && this.filtered.length === 0) {
+					leftContent = dim(" No matches");
+				}
+
+				// Right pane: preview (with scroll offset)
+				let rightContent = "";
+				const previewIdx = i + this.previewScroll;
+				if (previewIdx < previewLines.length) {
+					const rawLine = previewLines[previewIdx].replace(/\t/g, "  ");
+					const maxTxt = rightW - 2;
+					if (rawLine.startsWith("# ")) {
+						rightContent = " " + bold(accent(trunc(rawLine, maxTxt)));
+					} else if (rawLine.startsWith("## ")) {
+						rightContent = " " + accent(trunc(rawLine, maxTxt));
+					} else if (rawLine.match(/^\*\*\w/)) {
+						rightContent = " " + muted(trunc(rawLine, maxTxt));
+					} else if (rawLine.startsWith("- ")) {
+						rightContent = " " + trunc(rawLine, maxTxt);
+					} else {
+						rightContent = " " + dim(trunc(rawLine, maxTxt));
+					}
+				} else if (i === 0 && !selectedEntry) {
+					rightContent = dim(" No session selected");
+				}
+
+				lines.push(splitRow(leftContent, rightContent));
+			}
+
+			// ── Footer ──
+			lines.push(border("├" + "─".repeat(leftW) + "┴" + "─".repeat(rightW) + "┤"));
+			let countStr = "";
+			if (this.filtered.length > this.maxVisible) {
+				const shown = `${startIndex + 1}-${Math.min(startIndex + this.maxVisible, this.filtered.length)}`;
+				countStr = ` ${shown} of ${this.filtered.length}`;
+			} else if (this.filtered.length > 0) {
+				countStr = ` ${this.filtered.length} session${this.filtered.length === 1 ? "" : "s"}`;
+			}
+			const hints = "↑↓ navigate  pgup/dn preview  enter select  esc cancel";
+			lines.push(fullRow(dim(countStr + " ".repeat(Math.max(2, innerW - visibleWidth(countStr) - visibleWidth(hints))) + hints)));
+
+			// ── Bottom border ──
+			lines.push(border(`╰${"─".repeat(innerW)}╯`));
+
+			return this.clampLines(lines, maxW);
+		}
+
+		// Safety: clamp every line to terminal width
+		private clampLines(lines: string[], maxW: number): string[] {
+			return lines.map((line) => {
+				if (visibleWidth(line) > maxW) {
+					return truncateToWidth(line, maxW, "…");
+				}
+				return line;
+			});
+		}
+
+		invalidate(): void {}
+		dispose(): void {}
+	}
+
+	// Register /session-export command
+	// NOTE: Cannot use "export-session" because the built-in /export handler
+	// catches anything starting with "/export" before extension commands run.
+	pi.registerCommand("session-export", {
+		description: "Export a saved session to HTML. Usage: /session-export [path] or interactive picker",
 		handler: async (args, ctx) => {
 			const argStr = (args || "").trim();
 
@@ -1153,20 +1433,19 @@ After generating the summary, use the save_session_to_history tool to save it${c
 				// Direct path provided
 				summaryPath = argStr;
 			} else {
-				// Interactive: list recent sessions and let user pick
-				const result = await listSessionsByDateRange("last 7 days", 30);
-				if (!result || result.total === 0) {
-					ctx.ui.notify("No recent sessions found", "info");
+				// Interactive overlay picker
+				const files = collectSessionFiles(3);
+				if (files.length === 0) {
+					ctx.ui.notify("No sessions found", "info");
 					return;
 				}
 
-				const choices = result.files.map((f) => `${f.date} - ${f.desc}`);
-				const selected = await ctx.ui.select("Select session to export:", choices);
-				if (selected === undefined) return;
+				summaryPath = await ctx.ui.custom<string | null>(
+					(_tui, _theme, _kb, done) => new SessionPickerComponent(files, done),
+					{ overlay: true }
+				);
 
-				const idx = choices.indexOf(selected);
-				if (idx < 0) return;
-				summaryPath = result.files[idx].path;
+				if (!summaryPath) return;
 			}
 
 			if (!summaryPath || !existsSync(summaryPath)) {